feat(landing): секции по DESIGN.md

Шрифты Golos Text и Martian Mono через next/font, токены @theme,
все блоки по плану: первый экран со штампом даты ближайшей обжарки,
лента недели, этикетки сортов, подписка, кто мы, доставка, форма,
подвал. Три фото Unsplash локально, priority только у первого экрана.
LeadForm перекрашена под токены, логика и data-атрибуты те же.
В site.ts и form.ts добавлены служебные подписи для вёрстки.
This commit is contained in:
2026-10-17 16:56:52 +03:00
parent 66d69c1245
commit dc70ebccd8
26 changed files with 1364 additions and 65 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

+484
View File
@@ -1 +1,485 @@
@import 'tailwindcss';
/* Токены — DESIGN.md. Палитра Tailwind отключена: только цвета «Кварты». */
@theme {
--color-*: initial;
--color-cream: #f3ebdd;
--color-paper: #fbf7f0;
--color-kraft: #dcc7a1;
--color-roast: #1e120c;
--color-ink: #2a1810;
--color-muted: #5e4636;
--color-placeholder: #7a6352;
--color-rule-soft: #c9b595;
--color-cherry: #b3221b;
--color-cherry-deep: #8f1a14;
--color-cherry-on-dark: #e8604f;
--color-muted-on-dark: #bfa88a;
--color-ok: #2e5e3a;
--color-transparent: transparent;
--color-current: currentColor;
--radius-*: initial;
--radius-none: 0px;
--radius-hair: 2px;
--shadow-*: initial;
--text-display: clamp(2.75rem, 1.6rem + 5.2vw, 6rem);
--text-display--line-height: 0.92;
--text-display--letter-spacing: -0.035em;
--text-headline: clamp(2rem, 1.4rem + 2.8vw, 3.5rem);
--text-headline--line-height: 1;
--text-headline--letter-spacing: -0.03em;
--text-title: clamp(1.5rem, 1.3rem + 0.6vw, 1.875rem);
--text-title--line-height: 1.05;
--text-title--letter-spacing: -0.02em;
--text-lead: clamp(1.125rem, 1rem + 0.5vw, 1.375rem);
--text-lead--line-height: 1.45;
--text-body: 1.0625rem;
--text-body--line-height: 1.55;
--text-small: 0.875rem;
--text-small--line-height: 1.45;
--text-ui: 0.9375rem;
--text-ui--line-height: 1.3;
--text-tag: 0.75rem;
--text-tag--line-height: 1.3;
--text-tag--letter-spacing: 0.08em;
--text-price: clamp(1.375rem, 1.2rem + 0.8vw, 2rem);
--text-price--line-height: 1;
--text-price--letter-spacing: -0.02em;
--text-date: clamp(1.75rem, 1.2rem + 2.2vw, 3.25rem);
--text-date--line-height: 1;
--text-date--letter-spacing: -0.04em;
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
--ease-in-quart: cubic-bezier(0.5, 0, 0.75, 0);
--container-page: 1280px;
}
@theme inline {
--font-sans: var(--font-golos), system-ui, sans-serif;
--font-mono: var(--font-martian), ui-monospace, monospace;
}
@layer base {
html {
background: var(--color-cream);
color: var(--color-ink);
scroll-padding-top: 16px;
-webkit-text-size-adjust: 100%;
}
body {
font-family: var(--font-sans);
font-size: var(--text-body);
line-height: 1.55;
background: var(--color-cream);
-webkit-tap-highlight-color: transparent;
font-synthesis: none;
}
::selection {
background: var(--color-cherry);
color: var(--color-paper);
}
:focus-visible {
outline: 2px solid var(--color-ink);
outline-offset: 3px;
}
.on-dark :focus-visible {
outline-color: var(--color-cream);
}
input,
textarea {
caret-color: var(--color-cherry);
}
a {
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
h1,
h2,
h3 {
text-wrap: balance;
}
p {
text-wrap: pretty;
}
}
@layer components {
/* моно-шрифт: ширина через wdth-ось Martian Mono */
.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.mono-narrow {
font-family: var(--font-mono);
font-stretch: 87.5%;
font-variation-settings: 'wdth' 87.5;
}
.tag {
font-family: var(--font-mono);
font-size: var(--text-tag);
line-height: 1.3;
letter-spacing: 0.08em;
font-weight: 500;
text-transform: uppercase;
}
.page-x {
padding-inline: 20px;
}
@media (min-width: 1024px) {
.page-x {
padding-inline: 48px;
}
}
.container-page {
margin-inline: auto;
max-width: var(--container-page);
}
.section-y {
padding-block: 72px;
}
@media (min-width: 1024px) {
.section-y {
padding-block: 128px;
}
}
/* Кнопки */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 52px;
padding: 0 24px;
font-family: var(--font-sans);
font-size: 1rem;
font-weight: 700;
line-height: 1.2;
text-align: center;
border-radius: 0;
cursor: pointer;
user-select: none;
transition:
background-color 120ms ease-out,
color 120ms ease-out,
border-color 120ms ease-out,
transform 60ms linear;
}
.btn:active:not(:disabled) {
transform: translateY(1px);
}
.btn-primary {
background: var(--color-cherry);
color: var(--color-paper);
}
.btn-primary:active:not(:disabled) {
background: var(--color-cherry-deep);
}
.btn-secondary {
background: transparent;
color: var(--color-ink);
box-shadow: inset 0 0 0 1.5px var(--color-ink);
padding: 0 20px;
}
.btn-on-dark {
background: transparent;
color: var(--color-cream);
box-shadow: inset 0 0 0 1.5px var(--color-cream);
padding: 0 20px;
}
@media (hover: hover) {
.btn-primary:hover:not(:disabled) {
background: var(--color-cherry-deep);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-ink);
color: var(--color-cream);
}
.btn-on-dark:hover:not(:disabled) {
background: var(--color-cream);
color: var(--color-roast);
}
}
.btn:disabled:not([aria-busy]) {
background: var(--color-rule-soft);
color: var(--color-ink);
box-shadow: none;
cursor: not-allowed;
}
.btn[aria-busy='true'] {
cursor: progress;
}
.busy-dots {
display: inline-flex;
gap: 4px;
}
.busy-dots i {
width: 4px;
height: 4px;
background: currentColor;
}
/* Поля формы */
.field-label {
font-size: var(--text-ui);
font-weight: 600;
line-height: 1.3;
}
.field {
display: block;
width: 100%;
min-height: 52px;
padding: 12px 14px;
background: var(--color-paper);
color: var(--color-ink);
font-size: 1.0625rem;
line-height: 1.45;
border: 1.5px solid var(--color-muted);
border-radius: 0;
transition: border-color 120ms ease-out;
}
.field::placeholder {
color: var(--color-placeholder);
opacity: 1;
}
.field:focus {
border-color: var(--color-ink);
box-shadow: inset 0 0 0 0.5px var(--color-ink);
}
.field:focus-visible {
outline-offset: 2px;
}
.field[data-invalid] {
border-color: var(--color-cherry);
box-shadow: inset 0 0 0 0.5px var(--color-cherry);
}
textarea.field {
min-height: 112px;
resize: vertical;
}
.field-error {
display: flex;
gap: 6px;
align-items: flex-start;
font-size: var(--text-small);
font-weight: 500;
line-height: 1.45;
color: var(--color-cherry-deep);
}
.talon {
position: relative;
display: flex;
align-items: center;
gap: 10px;
min-height: 48px;
padding: 0 16px;
font-size: var(--text-ui);
font-weight: 600;
box-shadow: inset 0 0 0 1.5px var(--color-muted);
cursor: pointer;
transition:
background-color 120ms ease-out,
color 120ms ease-out;
}
.talon::before {
content: '';
width: 10px;
height: 10px;
flex: none;
box-shadow: inset 0 0 0 1.5px currentColor;
}
.talon[data-checked] {
background: var(--color-ink);
color: var(--color-cream);
box-shadow: inset 0 0 0 1.5px var(--color-ink);
}
.talon[data-checked]::before {
background: var(--color-cherry);
box-shadow: none;
}
.talon:has(:focus-visible) {
outline: 2px solid var(--color-ink);
outline-offset: 2px;
}
[data-invalid] .talon:not([data-checked]) {
box-shadow: inset 0 0 0 2px var(--color-cherry);
}
@media (hover: hover) {
.talon:not([data-checked]):hover {
background: var(--color-cream);
}
}
/* Этикетка: лента-наклейка, срезанная краем */
.ribbon {
position: absolute;
top: 34px;
right: -62px;
width: 220px;
padding: 6px 0;
background: var(--color-cherry);
color: var(--color-paper);
text-align: center;
transform: rotate(45deg);
pointer-events: none;
}
.perforation {
background-image: linear-gradient(to right, var(--color-ink) 0 6px, transparent 6px 10px);
background-size: 10px 1px;
background-repeat: repeat-x;
height: 1px;
}
}
/* Штамп */
@layer components {
.stamp {
display: inline-block;
transform: rotate(var(--stamp-rotate, -6deg));
opacity: 0.92;
}
.stamp-text {
font-family: var(--font-mono);
font-weight: 700;
font-stretch: 112.5%;
font-variation-settings: 'wdth' 112.5;
letter-spacing: 0.04em;
}
}
/* Лента недели: 390 — столбиком, ≥1024 — строкой */
@layer components {
.week-days {
display: grid;
grid-template-columns: minmax(0, 1fr) 128px;
column-gap: 12px;
}
.week-day {
grid-column: 1;
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
column-gap: 12px;
align-items: center;
min-height: 60px;
padding-block: 10px;
position: relative;
}
.week-head {
display: flex;
flex-direction: column;
gap: 2px;
}
.week-letter {
font-size: 0.875rem;
font-weight: 800;
letter-spacing: 0.04em;
line-height: 1;
}
.week-date {
font-size: var(--text-date);
font-weight: 700;
line-height: 1;
letter-spacing: -0.04em;
}
.week-rule {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: var(--color-ink);
transform-origin: left;
}
.week-mark {
position: absolute;
left: 0;
width: 64px;
bottom: -1px;
height: 4px;
background: var(--color-cherry);
transform-origin: left;
}
.week-note {
display: flex;
flex-direction: column;
gap: 2px;
}
.week-bracket {
grid-column: 2;
grid-row: 5 / 8;
display: flex;
gap: 10px;
padding-block: 12px;
}
.week-bracket-line {
flex: none;
width: 8px;
border: 2px solid var(--color-cherry);
border-left: 0;
transform-origin: top;
}
.week-bracket-note {
display: flex;
flex-direction: column;
gap: 4px;
align-self: center;
}
@media (min-width: 1024px) {
.week-days {
grid-template-columns: repeat(7, minmax(0, 1fr));
column-gap: 0;
}
.week-day {
grid-column: auto;
display: flex;
flex-direction: column;
align-items: stretch;
padding: 0;
min-height: 0;
}
.week-head {
position: relative;
gap: 10px;
padding: 0 16px 20px;
border-left: 1px solid var(--color-rule-soft);
}
.week-day:first-child .week-head {
border-left: 0;
padding-left: 0;
}
.week-letter {
font-size: 1rem;
}
.week-mark {
left: 16px;
right: 16px;
width: auto;
}
.week-note {
padding: 16px 16px 0;
}
.week-bracket {
grid-column: 5 / 8;
grid-row: 2;
flex-direction: column;
gap: 12px;
padding: 28px 16px 0;
}
.week-bracket-line {
width: auto;
height: 10px;
border: 2px solid var(--color-cherry);
border-top: 0;
transform-origin: left;
}
.week-bracket-note {
align-self: auto;
}
}
}
+15 -4
View File
@@ -1,16 +1,27 @@
import type { Metadata } from 'next';
import type { Metadata, Viewport } from 'next';
import { Golos_Text, Martian_Mono } from 'next/font/google';
import { StampInkFilter } from '@/components/Stamp';
import { TouchRecorder } from '@/components/TouchRecorder';
import { site } from '@/content/site';
import './globals.css';
const golos = Golos_Text({ subsets: ['cyrillic', 'latin'], variable: '--font-golos', display: 'swap' });
const martian = Martian_Mono({ subsets: ['cyrillic', 'latin'], variable: '--font-martian', axes: ['wdth'], display: 'swap' });
export const metadata: Metadata = {
title: 'Кварта — свежая обжарка в Минске',
description: 'Обжариваем по четвергам, привозим по пятницам.',
title: site.meta.title,
description: site.meta.description,
};
export const viewport: Viewport = {
themeColor: '#f3ebdd',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<html lang="ru" className={`${golos.variable} ${martian.variable}`}>
<body>
<StampInkFilter />
{children}
<TouchRecorder />
</body>
+27 -25
View File
@@ -1,31 +1,33 @@
// Заглушка страницы: проверяем форму и CTA. Секции и вёрстку сделает дизайнер.
import { CtaButton } from '@/components/CtaButton';
import { LeadForm } from '@/components/LeadForm';
import { CTAS, FORM_TEXT } from '@/content/form';
import { LEAD_SECTION_ID } from '@/lib/leadPrefill';
import { About } from '@/components/sections/About';
import { Catalog } from '@/components/sections/Catalog';
import { Delivery } from '@/components/sections/Delivery';
import { Footer } from '@/components/sections/Footer';
import { Freshness } from '@/components/sections/Freshness';
import { Header } from '@/components/sections/Header';
import { Hero } from '@/components/sections/Hero';
import { LeadSection } from '@/components/sections/LeadSection';
import { Subscription } from '@/components/sections/Subscription';
import { formatShortDate, getRoastWeek } from '@/lib/roastWeek';
const ctaClass = 'rounded border border-neutral-900 px-4 py-2';
// дата ближайшей обжарки пересчитывается раз в час
export const revalidate = 3600;
export default function Page() {
const week = getRoastWeek();
const roastDate = formatShortDate(week.roast);
return (
<main className="mx-auto flex max-w-2xl flex-col gap-12 px-4 py-10 font-sans">
<section id="hero" className="flex flex-col gap-4">
<h1 className="text-3xl font-bold">Кварта — свежая обжарка в Минске</h1>
<p>Обжариваем по четвергам, привозим по пятницам.</p>
<div className="flex flex-wrap gap-3">
{Object.values(CTAS).map((cta) => (
<CtaButton key={cta.label} interest={cta.interest} comment={'comment' in cta ? cta.comment : undefined} className={ctaClass}>
{cta.label}
</CtaButton>
))}
</div>
</section>
<section id={LEAD_SECTION_ID} className="flex scroll-mt-4 flex-col gap-4">
<h2 className="text-2xl font-bold">{FORM_TEXT.title}</h2>
<p>{FORM_TEXT.lead}</p>
<LeadForm />
</section>
</main>
<>
<Header />
<main id="top">
<Hero roastDate={roastDate} />
<Freshness week={week} />
<Catalog roastDate={roastDate} />
<Subscription />
<About />
<Delivery />
<LeadSection />
</main>
<Footer />
</>
);
}
+96
View File
@@ -0,0 +1,96 @@
// Этикетка пачки: крафт, поля через линейки, лента «обжарено в чт», штамп даты обжарки.
import { site } from '@/content/site';
import { CtaButton } from './CtaButton';
import { PriceTag } from './PriceTag';
import { Stamp } from './Stamp';
type Item = (typeof site.catalog.items)[number];
const ROTATIONS: Record<string, number> = { ethiopia: -4, colombia: 3, brazil: -2, drip: 5 };
function splitName(name: string): [string, string] {
const i = name.indexOf(' ');
return i === -1 ? [name, ''] : [name.slice(0, i), name.slice(i + 1)];
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex min-h-10 items-center gap-3 border-t border-ink py-2">
<span className="tag w-[6.25rem] flex-none text-muted lg:w-[7.5rem]">{label}</span>
<span className="min-w-0">{children}</span>
</div>
);
}
export function Label({ item, roastDate, wide = false }: { item: Item; roastDate: string; wide?: boolean }) {
const [title, region] = splitName(item.name);
const { label } = site.catalog;
const extra = item.roast ?? item.brew;
const head = (
<div>
<p className="tag text-muted">{label.brand}</p>
<h3 id={`label-${item.id}`} className="mt-5 text-title font-extrabold">
{title}
{region && <span className="mt-1 block text-small font-medium tracking-normal text-muted">{region}</span>}
</h3>
{extra && <p className="mt-3 text-small font-medium text-ink">{extra}</p>}
</div>
);
const fields = (
<div>
{item.notes.length > 0 && (
<Row label={label.notes}>
<span className="tag text-ink">{item.notes.join(' • ')}</span>
</Row>
)}
<Row label={site.roastDate.label}>
<Stamp lines={[{ text: `ЧТ ${roastDate}`, size: 'lg' }]} width={136} rotate={ROTATIONS[item.id] ?? -3} />
<span className="sr-only">
{site.roastDate.srPrefix}: {roastDate}
</span>
</Row>
</div>
);
const buy = (
<div className="flex flex-col gap-4">
<PriceTag size={item.size} price={item.price} />
<CtaButton
interest={item.cta.interest}
comment={item.cta.comment}
source="coffee"
className="btn btn-secondary w-full justify-between"
>
{item.cta.label}
<Arrow />
</CtaButton>
</div>
);
return (
<article
className={`relative overflow-hidden border border-ink bg-kraft p-5 lg:p-6 ${
wide ? 'flex flex-col gap-6 lg:grid lg:grid-cols-[1fr_1.4fr_1fr] lg:items-end lg:gap-10 lg:pr-24' : 'flex flex-col gap-6'
}`}
aria-labelledby={`label-${item.id}`}
>
{item.id === 'drip' && <span className="perforation absolute inset-x-0 top-2" aria-hidden="true" />}
<span className="ribbon tag" aria-hidden="true">
{label.ribbon}
</span>
{head}
<div className={wide ? '' : 'mt-auto'}>{fields}</div>
{buy}
</article>
);
}
function Arrow() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M2 8h11M9 4l4 4-4 4" />
</svg>
);
}
+85 -31
View File
@@ -5,6 +5,7 @@ import { INTERESTS, INTEREST_LABELS, type LeadField } from '@kvarta/shared/lead'
import { useEffect, useRef } from 'react';
import { CONTACT_PHONE, FORM_TEXT } from '@/content/form';
import { LEAD_NAME_INPUT_ID } from '@/lib/leadPrefill';
import { Stamp } from './Stamp';
import type { SendLead, SubmitNotice } from '@/lib/sendLead';
import { useLeadForm } from '@/lib/useLeadForm';
@@ -30,30 +31,63 @@ function capitalize(s: string) {
function PhoneLink() {
return (
<a href={CONTACT_PHONE.href} className="whitespace-nowrap underline">
<a href={CONTACT_PHONE.href} className="mono inline-flex min-h-11 items-center text-[18px] font-semibold whitespace-nowrap underline">
{CONTACT_PHONE.display}
</a>
);
}
function Accepted({ stamp, text }: { stamp: string; text: string }) {
return (
<div className="flex flex-col items-start gap-5 sm:flex-row sm:items-center sm:gap-8">
<Stamp lines={[{ text: stamp, size: 'lg' }]} width={168} rotate={-5} animate="event" className="flex-none" />
<p className="flex items-start gap-3 text-title font-extrabold">
<CheckIcon />
{text}
</p>
</div>
);
}
function Failed({ text }: { text: string }) {
return (
<div className="border-t-2 border-cherry bg-paper pt-4">
<p className="tag text-cherry-deep">{FORM_TEXT.errorTag}</p>
<p className="mt-2">
{text} <PhoneLink />
</p>
</div>
);
}
function CheckIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="var(--color-ok)" strokeWidth="2" className="mt-1 flex-none" aria-hidden="true" focusable="false">
<path d="M3 10.5 8 15 17 5" />
</svg>
);
}
function ErrorIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" className="mt-[3px] flex-none" aria-hidden="true" focusable="false">
<rect width="14" height="14" fill="var(--color-cherry)" />
<rect x="6.25" y="3" width="1.5" height="5" fill="var(--color-paper)" />
<rect x="6.25" y="9.5" width="1.5" height="1.5" fill="var(--color-paper)" />
</svg>
);
}
function Notice({ notice }: { notice: SubmitNotice | null }) {
switch (notice) {
case 'created':
return <p>{FORM_TEXT.success}</p>;
return <Accepted stamp={FORM_TEXT.stamp.created} text={FORM_TEXT.success} />;
case 'clarified':
return <p>{FORM_TEXT.clarified}</p>;
return <Accepted stamp={FORM_TEXT.stamp.clarified} text={FORM_TEXT.clarified} />;
case 'rate_limited':
return (
<p>
{FORM_TEXT.rateLimited} <PhoneLink />
</p>
);
return <Failed text={FORM_TEXT.rateLimited} />;
case 'failed':
return (
<p>
{FORM_TEXT.failed} <PhoneLink />
</p>
);
return <Failed text={FORM_TEXT.failed} />;
default:
// invalid — подсказки уже под полями
return null;
@@ -79,24 +113,32 @@ export function LeadForm({ sendLead, className }: LeadFormProps) {
const fieldError = (field: LeadField) =>
errors[field] ? (
<p id={errorId(field)} className="text-sm text-red-700" data-field-error={field}>
<p id={errorId(field)} className="field-error" data-field-error={field}>
<ErrorIcon />
{errors[field]}
</p>
) : null;
return (
<div className={`relative flex flex-col gap-4 ${className ?? ''}`} data-state={status} data-notice={notice ?? undefined}>
<div
className={`lead-form relative flex flex-col gap-6 border-2 border-ink bg-paper p-5 lg:p-10 ${className ?? ''}`}
data-state={status}
data-notice={notice ?? undefined}
>
<p className="tag text-muted" aria-hidden="true">
{FORM_TEXT.formTag}
</p>
{status !== 'success' && (
<form noValidate onSubmit={form.submit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label htmlFor={ids.name}>{FORM_TEXT.name.label}</label>
<form noValidate onSubmit={form.submit} className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<label className="field-label" htmlFor={ids.name}>{FORM_TEXT.name.label}</label>
<input
id={ids.name}
name="name"
type="text"
autoComplete="name"
placeholder={FORM_TEXT.name.placeholder}
className="rounded border border-neutral-400 px-3 py-2"
className="field"
value={values.name}
onChange={(e) => form.setField('name', e.target.value)}
onBlur={() => form.blurField('name')}
@@ -105,8 +147,8 @@ export function LeadForm({ sendLead, className }: LeadFormProps) {
{fieldError('name')}
</div>
<div className="flex flex-col gap-1">
<label htmlFor={ids.phone}>{FORM_TEXT.phone.label}</label>
<div className="flex flex-col gap-2">
<label className="field-label" htmlFor={ids.phone}>{FORM_TEXT.phone.label}</label>
<input
id={ids.phone}
name="phone"
@@ -114,7 +156,7 @@ export function LeadForm({ sendLead, className }: LeadFormProps) {
inputMode="tel"
autoComplete="tel"
placeholder={FORM_TEXT.phone.placeholder}
className="rounded border border-neutral-400 px-3 py-2"
className="field"
value={values.phone}
onChange={(e) => form.setField('phone', e.target.value)}
onBlur={() => form.blurField('phone')}
@@ -128,15 +170,16 @@ export function LeadForm({ sendLead, className }: LeadFormProps) {
className="flex flex-col gap-1"
{...fieldProps('interest')}
>
<legend>{FORM_TEXT.interest.legend}</legend>
<div className="flex flex-wrap gap-4">
<legend className="field-label mb-2">{FORM_TEXT.interest.legend}</legend>
<div className="flex flex-col gap-2 md:flex-row">
{INTERESTS.map((interest) => (
<label
key={interest}
className="flex items-center gap-2"
className="talon md:flex-1"
data-checked={values.interest === interest ? '' : undefined}
>
<input
className="sr-only"
type="radio"
name="interest"
value={interest}
@@ -151,14 +194,14 @@ export function LeadForm({ sendLead, className }: LeadFormProps) {
{fieldError('interest')}
</fieldset>
<div className="flex flex-col gap-1">
<label htmlFor={ids.comment}>{FORM_TEXT.comment.label}</label>
<div className="flex flex-col gap-2">
<label className="field-label" htmlFor={ids.comment}>{FORM_TEXT.comment.label}</label>
<textarea
id={ids.comment}
name="comment"
rows={3}
placeholder={FORM_TEXT.comment.placeholder}
className="rounded border border-neutral-400 px-3 py-2"
className="field"
value={values.comment}
onChange={(e) => form.setField('comment', e.target.value)}
onBlur={() => form.blurField('comment')}
@@ -183,11 +226,22 @@ export function LeadForm({ sendLead, className }: LeadFormProps) {
<button
type="submit"
className="self-start rounded bg-neutral-900 px-5 py-2 text-white disabled:opacity-60"
className="btn btn-primary mt-2 w-full sm:w-auto sm:min-w-[240px] sm:self-start"
disabled={submitting}
aria-busy={submitting || undefined}
>
{submitting ? FORM_TEXT.submitting : FORM_TEXT.submit}
{submitting ? (
<>
{FORM_TEXT.submitting}
<span className="busy-dots" aria-hidden="true">
<i />
<i />
<i />
</span>
</>
) : (
FORM_TEXT.submit
)}
</button>
</form>
)}
@@ -200,7 +254,7 @@ export function LeadForm({ sendLead, className }: LeadFormProps) {
<button
ref={sendAnotherRef}
type="button"
className="self-start rounded border border-neutral-900 px-5 py-2"
className="btn btn-secondary w-full sm:w-auto sm:self-start"
onClick={() => {
form.reset();
requestAnimationFrame(() => document.getElementById(LEAD_NAME_INPUT_ID)?.focus());
+20
View File
@@ -0,0 +1,20 @@
// Знак «Кварты»: семь штрихов-дней, четвёртый (четверг) красный. Плюс слово.
export function LogoMark({ className, onDark = false }: { className?: string; onDark?: boolean }) {
const base = onDark ? 'var(--color-cream)' : 'var(--color-ink)';
return (
<svg className={className} viewBox="0 0 39 20" width="39" height="20" aria-hidden="true" focusable="false">
{Array.from({ length: 7 }, (_, i) => (
<rect key={i} x={i * 6} y={0} width={3} height={20} fill={i === 3 ? 'var(--color-cherry)' : base} />
))}
</svg>
);
}
export function Logo({ name, onDark = false }: { name: string; onDark?: boolean }) {
return (
<span className="inline-flex items-center gap-2.5">
<LogoMark onDark={onDark} />
<span className="text-[22px] leading-none font-black tracking-[-0.02em]">{name}</span>
</span>
);
}
+15
View File
@@ -0,0 +1,15 @@
// Вес слева, цена справа моноширинным; «BYN» мельче.
export function PriceTag({ size, price, suffix, large = false }: { size?: string; price: number; suffix?: string; large?: boolean }) {
return (
<p className="flex items-baseline justify-between gap-4 border-t border-ink pt-3">
{size ? <span className="mono text-base font-medium">{size}</span> : <span />}
<span className="mono whitespace-nowrap">
<span className={`${large ? 'text-[clamp(2.25rem,1.6rem+2vw,3rem)]' : 'text-price'} font-semibold tracking-[-0.02em]`}>
{price}
</span>
<span className={`${large ? 'text-xl' : 'text-sm'} ml-1.5 font-semibold`}>BYN</span>
{suffix && <span className="ml-2 text-base font-medium text-muted">{suffix}</span>}
</span>
</p>
);
}
+84
View File
@@ -0,0 +1,84 @@
// Резиновый штамп даты: двойная рамка, Martian Mono wdth 112.5, неровная «краска» (фильтр #stamp-ink в layout).
// Анимируется обёртка (transform/opacity), фильтр статичен.
import type { CSSProperties } from 'react';
export type StampLine = { text: string; size?: 'sm' | 'lg' };
export type StampProps = {
lines: StampLine[];
/** ширина: px или CSS-значение */
width?: number | string;
/** поворот, градусы */
rotate?: number;
tone?: 'cherry' | 'cherry-on-dark';
/** 'load' — оттиск при загрузке, 'event' — когда появился в DOM (успех формы), иначе статичен */
animate?: 'load' | 'event';
/** бумажная подложка — когда штамп лежит на фото */
backing?: boolean;
className?: string;
};
const W = 200;
const LINE_H = { sm: 26, lg: 46 } as const;
const FONT = { sm: 15, lg: 34 } as const;
const PAD = 16;
export function Stamp({ lines, width = 168, rotate = -6, tone = 'cherry', animate, backing = false, className }: StampProps) {
const heights = lines.map((l) => LINE_H[l.size ?? 'sm']);
const H = PAD * 2 + heights.reduce((a, b) => a + b, 0);
const tops = heights.map((_, i) => PAD + heights.slice(0, i).reduce((a, b) => a + b, 0));
const color = `var(--color-${tone})`;
const style = { '--stamp-rotate': `${rotate}deg`, width } as CSSProperties;
return (
<span className={`stamp ${className ?? ''}`} style={style} data-stamp={animate} aria-hidden="true">
<svg viewBox={`0 0 ${W} ${H}`} width="100%" focusable="false" style={{ display: 'block', overflow: 'visible' }}>
{backing && <rect x={-6} y={-6} width={W + 12} height={H + 12} fill="var(--color-paper)" />}
<g filter="url(#stamp-ink)" fill={color} stroke={color}>
<rect x={1} y={1} width={W - 2} height={H - 2} fill="none" strokeWidth={2.5} />
<rect x={6} y={6} width={W - 12} height={H - 12} fill="none" strokeWidth={1.2} />
<g transform="translate(1.2 -1)" stroke="none">
{lines.map((line, i) => {
const size = line.size ?? 'sm';
const h = heights[i] ?? 0;
const top = tops[i] ?? PAD;
return (
<g key={i}>
{i > 0 && <rect x={12} y={top - 0.6} width={W - 24} height={1.2} />}
<text
x={W / 2}
y={top + h / 2 + FONT[size] * 0.36}
textAnchor="middle"
fontSize={FONT[size]}
className="stamp-text"
textLength={line.text.length > 9 || size === 'lg' ? W - 40 : undefined}
lengthAdjust="spacingAndGlyphs"
>
{line.text.toUpperCase()}
</text>
</g>
);
})}
</g>
</g>
</svg>
</span>
);
}
/** Один на страницу: фильтр «неровной краски» для всех штампов. */
export function StampInkFilter() {
return (
<svg width="0" height="0" aria-hidden="true" focusable="false" style={{ position: 'absolute' }}>
<defs>
<filter id="stamp-ink" x="-4%" y="-4%" width="108%" height="108%" colorInterpolationFilters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves={2} seed={4} result="grain" />
<feDisplacementMap in="SourceGraphic" in2="grain" scale={1.8} xChannelSelector="R" yChannelSelector="G" result="shaky" />
<feTurbulence type="fractalNoise" baseFrequency="0.07" numOctaves={3} seed={11} result="blotch" />
<feColorMatrix in="blotch" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -3.2 0 0 0 2.35" result="mask" />
<feComposite in="shaky" in2="mask" operator="in" />
</filter>
</defs>
</svg>
);
}
+49
View File
@@ -0,0 +1,49 @@
// Лента недели: дни «буква + число», ЧТ (обжарка) и ПТ (доставка) красным, скобка ПТ–ВС «1–3 день — в чашке».
// 390 — столбиком, ≥1024 — строкой. Анимация — в CSS по [data-inview] (WeekStripObserver).
import { site } from '@/content/site';
import type { RoastWeek } from '@/lib/roastWeek';
const [roastNote, deliveryNote, cupNote] = site.freshness.days;
const NOTES: Record<number, (typeof site.freshness.days)[number] | undefined> = { 3: roastNote, 4: deliveryNote };
export function WeekStrip({ week }: { week: RoastWeek }) {
return (
<div className="week" data-reveal="">
<ol className="week-days" aria-label={site.freshness.weekLabel}>
{week.days.map((day, i) => {
const note = NOTES[i];
const hot = note !== undefined;
return (
<li key={day.iso} className="week-day" data-hot={hot ? '' : undefined}>
<div className="week-head">
<span className={`week-letter ${hot ? 'text-cherry' : 'text-muted'}`}>{day.letter}</span>
<time dateTime={day.iso} className={`week-date mono-narrow ${hot ? 'text-cherry' : 'text-ink'}`}>
{day.date}
</time>
<span className="week-rule" aria-hidden="true" />
{hot && <span className="week-mark" aria-hidden="true" />}
</div>
{note && (
<div className="week-note">
<p className="tag text-cherry">{note.label}</p>
<p className="text-small text-ink">{note.caption}</p>
</div>
)}
</li>
);
})}
{cupNote && (
<li className="week-bracket">
<span className="week-bracket-line" aria-hidden="true" />
<div className="week-bracket-note">
<p className="tag text-cherry">
{cupNote.day} · {cupNote.label}
</p>
<p className="text-small text-ink">{cupNote.caption}</p>
</div>
</li>
)}
</ol>
</div>
);
}
@@ -0,0 +1,41 @@
import Image from 'next/image';
import { site } from '@/content/site';
import cooler from '../../../public/photos/cooler.jpg';
export function About() {
const { about } = site;
return (
<section id="about" aria-labelledby="about-title" className="page-x section-y">
<div className="container-page grid gap-10 border-t-2 border-ink pt-10 lg:grid-cols-12 lg:items-end lg:gap-6 lg:pt-14">
<div className="relative -mx-5 aspect-[3/2] overflow-hidden lg:col-span-6 lg:mx-0 lg:aspect-[5/4]">
<Image
src={cooler}
alt={site.images.cooler.alt}
fill
sizes="(min-width: 1280px) 610px, (min-width: 1024px) 48vw, 100vw"
className="object-cover object-[center_40%]"
placeholder="blur"
/>
</div>
<div className="lg:col-span-5 lg:col-start-8">
<h2 id="about-title" className="text-headline font-extrabold">
{about.title}
</h2>
<div className="mt-5 flex max-w-[62ch] flex-col gap-4">
{about.text.map((p) => (
<p key={p}>{p}</p>
))}
</div>
<dl className="mt-10 grid grid-cols-3 border-y border-ink">
{about.facts.map((fact) => (
<div key={fact.label} className="flex flex-col-reverse justify-end gap-2 border-l border-ink py-4 pl-3 first:border-l-0 first:pl-0">
<dt className="tag text-muted">{fact.label}</dt>
<dd className="mono text-[22px] leading-none font-semibold">{fact.value}</dd>
</div>
))}
</dl>
</div>
</div>
</section>
);
}
@@ -0,0 +1,28 @@
import { site } from '@/content/site';
import { Label } from '../Label';
export function Catalog({ roastDate }: { roastDate: string }) {
const { catalog } = site;
const packs = catalog.items.filter((item) => item.id !== 'drip');
const drip = catalog.items.find((item) => item.id === 'drip');
return (
<section id="coffee" aria-labelledby="coffee-title" className="page-x section-y">
<div className="container-page border-t-2 border-ink pt-10 lg:pt-14">
<h2 id="coffee-title" className="text-headline font-extrabold">
{catalog.title}
</h2>
<p className="mt-4 max-w-[46ch] text-lead">{catalog.subtitle}</p>
<div className="mt-10 grid gap-4 md:grid-cols-2 lg:mt-16 lg:grid-cols-3 lg:gap-6">
{packs.map((item) => (
<Label key={item.id} item={item} roastDate={roastDate} />
))}
{drip && (
<div className="md:col-span-2 lg:col-span-3">
<Label item={drip} roastDate={roastDate} wide />
</div>
)}
</div>
</div>
</section>
);
}
@@ -0,0 +1,70 @@
import Image from 'next/image';
import { site } from '@/content/site';
import { CtaButton } from '../CtaButton';
import sack from '../../../public/photos/sack.jpg';
export function Delivery() {
const { delivery } = site;
const { pickup } = delivery;
return (
<section id="delivery" aria-labelledby="delivery-title" className="on-dark bg-roast text-cream">
<div className="grid lg:grid-cols-12">
<div className="relative aspect-[390/230] lg:col-span-5 lg:aspect-auto lg:min-h-[640px]">
<Image
src={sack}
alt={site.images.sack.alt}
fill
sizes="(min-width: 1024px) 42vw, 100vw"
className="object-cover object-[center_45%]"
placeholder="blur"
/>
</div>
<div className="page-x section-y lg:col-span-7 lg:pr-12 lg:pl-16">
<div className="max-w-[720px]">
<h2 id="delivery-title" className="text-headline font-extrabold">
{delivery.title}
</h2>
<div className="mt-10 grid gap-10 md:grid-cols-2 md:gap-8 lg:mt-14">
<div>
<h3 className="text-title font-extrabold">{delivery.delivery.title}</h3>
<ul className="mt-5">
{delivery.delivery.lines.map((line) => (
<li key={line} className="border-t border-muted-on-dark py-3 first:border-t-0 first:pt-0">
{line}
</li>
))}
</ul>
</div>
<div>
<h3 className="text-title font-extrabold">{pickup.title}</h3>
<p className="mt-5">{pickup.address}</p>
<dl className="mt-3">
{pickup.hours.map((row) => (
<div key={row.days} className="flex items-baseline justify-between gap-4 border-t border-muted-on-dark py-3">
<dt className="tag text-muted-on-dark">{row.days}</dt>
<dd className="mono text-ui font-medium">{row.time}</dd>
</div>
))}
</dl>
<a
href={pickup.map.href}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex min-h-11 items-center gap-2 text-ui font-semibold underline"
>
{pickup.map.label}
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true" focusable="false">
<path d="M5 3h8v8M13 3 3 13" />
</svg>
</a>
</div>
</div>
<CtaButton interest={delivery.cta.interest} className="btn btn-on-dark mt-12 w-full sm:w-auto">
{delivery.cta.label}
</CtaButton>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,30 @@
import { site } from '@/content/site';
import { Logo } from '../Logo';
export function Footer() {
const { footer } = site;
return (
<footer className="on-dark page-x bg-roast pt-16 pb-[calc(88px+env(safe-area-inset-bottom))] text-cream md:pb-16">
<div className="container-page grid gap-8 md:grid-cols-2 lg:grid-cols-4 lg:gap-6">
<div>
<Logo name={footer.name} onDark />
<p className="mt-4 text-small text-muted-on-dark">{footer.tagline}</p>
</div>
<div className="text-small">
<p>{footer.address}</p>
<p className="mt-1 text-muted-on-dark">{footer.hours}</p>
</div>
<div className="text-small">
<a href={footer.phone.href} className="mono inline-flex min-h-11 items-center text-ui font-medium">
{footer.phone.display}
</a>
<p className="text-muted-on-dark">{footer.phoneNote}</p>
</div>
<div className="text-small text-muted-on-dark">
<p>{footer.photoCredit}</p>
<p className="mt-1">{footer.disclaimer}</p>
</div>
</div>
</footer>
);
}
@@ -0,0 +1,20 @@
import { site } from '@/content/site';
import type { RoastWeek } from '@/lib/roastWeek';
import { WeekStrip } from '../WeekStrip';
export function Freshness({ week }: { week: RoastWeek }) {
const { freshness } = site;
return (
<section id="fresh" aria-labelledby="fresh-title" className="page-x section-y">
<div className="container-page border-t-2 border-ink pt-10 lg:pt-14">
<h2 id="fresh-title" className="text-headline font-extrabold">
{freshness.title}
</h2>
<p className="mt-4 max-w-[46ch] text-lead">{freshness.why}</p>
<div className="mt-10 lg:mt-16">
<WeekStrip week={week} />
</div>
</div>
</section>
);
}
@@ -0,0 +1,40 @@
import { CONTACT_PHONE } from '@/content/form';
import { site } from '@/content/site';
import { Logo } from '../Logo';
export function Header() {
return (
<header className="page-x">
<div className="container-page flex h-16 items-center justify-between gap-6">
<a href="#top" className="-mx-1 flex min-h-11 items-center px-1 no-underline" aria-label={site.footer.name}>
<Logo name={site.footer.name} />
</a>
<nav aria-label="Разделы" className="hidden items-center gap-8 lg:flex">
{site.header.nav.map((item) => (
<a key={item.href} href={item.href} className="flex min-h-11 items-center text-ui font-semibold no-underline hover:underline">
{item.label}
</a>
))}
<a href={CONTACT_PHONE.href} className="mono flex min-h-11 items-center text-ui font-medium no-underline hover:underline">
{CONTACT_PHONE.display}
</a>
</nav>
<a
href={CONTACT_PHONE.href}
className="-mr-2.5 flex size-11 items-center justify-center lg:hidden"
aria-label={`${site.header.callLabel}: ${CONTACT_PHONE.display}`}
>
<PhoneIcon />
</a>
</div>
</header>
);
}
function PhoneIcon() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true" focusable="false">
<path d="M5 3h4l2 5-2.5 1.5a11 11 0 0 0 6 6L16 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 5a2 2 0 0 1 2-2" strokeLinejoin="round" />
</svg>
);
}
@@ -0,0 +1,49 @@
import Image from 'next/image';
import { site } from '@/content/site';
import { CtaButton } from '../CtaButton';
import { Stamp } from '../Stamp';
import beans from '../../../public/photos/beans.jpg';
export function Hero({ roastDate }: { roastDate: string }) {
const { hero } = site;
return (
<section id="hero" aria-labelledby="hero-title" className="page-x pt-6 lg:pt-12">
<div className="container-page grid gap-y-8 lg:grid-cols-12 lg:gap-x-6">
<div className="flex flex-col lg:col-span-7 lg:pt-10">
<h1 id="hero-title" className="max-w-[11ch] text-display font-black">
{hero.title}
</h1>
<p className="mt-5 max-w-[34ch] text-lead text-ink lg:mt-8">{hero.subtitle}</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row lg:mt-10" data-hero-cta="">
<CtaButton interest={hero.ctaPrimary.interest} className="btn btn-primary w-full sm:w-auto">
{hero.ctaPrimary.label}
</CtaButton>
<CtaButton interest={hero.ctaSecondary.interest} className="btn btn-secondary w-full sm:w-auto">
{hero.ctaSecondary.label}
</CtaButton>
</div>
</div>
<div className="relative -mx-5 mt-14 lg:col-span-5 lg:mx-0 lg:mt-0">
<div className="relative aspect-[390/300] overflow-hidden lg:aspect-[4/5]">
<Image
src={beans}
alt={site.images.beans.alt}
fill
priority
fetchPriority="high"
sizes="(min-width: 1280px) 500px, (min-width: 1024px) 38vw, 100vw"
className="object-cover"
placeholder="blur"
/>
</div>
<div className="absolute -top-14 left-5 w-[184px] lg:top-auto lg:-bottom-10 lg:-left-16 lg:w-[232px]">
<Stamp lines={[{ text: hero.stamp }, { text: roastDate, size: 'lg' }]} width="100%" rotate={-6} animate="load" backing />
<span className="sr-only">
{site.roastDate.srPrefix}: {roastDate}
</span>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,37 @@
import { CONTACT_PHONE } from '@/content/form';
import { site } from '@/content/site';
import { LEAD_SECTION_ID } from '@/lib/leadPrefill';
import { LeadForm } from '../LeadForm';
export function LeadSection() {
const { lead, delivery } = site;
return (
<section id={LEAD_SECTION_ID} aria-labelledby="lead-title" className="page-x section-y scroll-mt-4">
<div className="container-page grid gap-10 lg:grid-cols-12 lg:gap-6">
<div className="lg:col-span-5">
<h2 id="lead-title" className="text-headline font-extrabold">
{lead.title}
</h2>
<p className="mt-4 max-w-[36ch] text-lead">{lead.subtitle}</p>
<p className="mt-8">
{lead.call}{' '}
<a href={CONTACT_PHONE.href} className="mono inline-flex min-h-11 items-center text-[18px] font-semibold whitespace-nowrap lg:text-[22px]">
{CONTACT_PHONE.display}
</a>
</p>
<dl className="mt-4 hidden max-w-[320px] lg:block">
{delivery.pickup.hours.map((row) => (
<div key={row.days} className="flex items-baseline justify-between gap-4 border-t border-ink py-2">
<dt className="tag text-muted">{row.days}</dt>
<dd className="mono text-ui font-medium">{row.time}</dd>
</div>
))}
</dl>
</div>
<div className="lg:col-span-7">
<LeadForm />
</div>
</div>
</section>
);
}
@@ -0,0 +1,62 @@
import { site } from '@/content/site';
import { CtaButton } from '../CtaButton';
import { LogoMark } from '../Logo';
import { PriceTag } from '../PriceTag';
export function Subscription() {
const { subscription: sub } = site;
const suffix = sub.priceDisplay.replace(`${sub.price} BYN`, '').trim();
return (
<section id="subscription" aria-labelledby="subscription-title" className="page-x section-y">
<div className="container-page grid gap-10 border-t-2 border-ink pt-10 lg:grid-cols-12 lg:gap-6 lg:pt-14">
<div className="lg:col-span-5">
<h2 id="subscription-title" className="text-headline font-extrabold">
{sub.title}
</h2>
<p className="mt-4 max-w-[40ch] text-lead">{sub.text}</p>
</div>
<div className="border border-ink bg-kraft p-5 lg:col-span-7 lg:grid lg:grid-cols-[1.2fr_1fr] lg:gap-10 lg:p-8">
<div className="flex flex-col gap-6">
<Packs />
<dl>
{sub.fields.map((field) => (
<div key={field.label} className="flex min-h-10 items-center gap-3 border-t border-ink py-2">
<dt className="tag w-[6.25rem] flex-none text-muted lg:w-[7.5rem]">{field.label}</dt>
<dd className="tag text-ink">{field.value}</dd>
</div>
))}
</dl>
</div>
<div className="mt-8 flex flex-col justify-end gap-4 lg:mt-0">
<PriceTag price={sub.price} suffix={suffix} large />
<CtaButton interest={sub.cta.interest} comment={sub.cta.comment} className="btn btn-primary w-full">
{sub.cta.label}
</CtaButton>
<p className="text-small text-muted">{sub.note}</p>
</div>
</div>
</div>
</section>
);
}
/** Две пачки: силуэты с клапаном, на второй — знак. */
function Packs() {
const pack = (x: number) => (
<g transform={`translate(${x} 0)`}>
<path d="M4 14 L10 2 H54 L60 14 V98 H4 Z" />
<path d="M4 14 H60" />
<path d="M14 30 H50" />
</g>
);
return (
<div className="relative w-[156px]" aria-hidden="true">
<svg viewBox="0 0 156 100" width="156" height="100" fill="none" stroke="var(--color-ink)" strokeWidth="1.5" focusable="false">
{pack(1)}
{pack(91)}
</svg>
<LogoMark className="absolute top-[52px] left-[103px] h-[14px] w-[27px]" />
<LogoMark className="absolute top-[52px] left-[13px] h-[14px] w-[27px]" />
</div>
);
}
+4
View File
@@ -21,6 +21,10 @@ export const FORM_TEXT = {
sendAnother: 'Отправить ещё одну',
rateLimited: 'Слишком много попыток. Попробуйте через 10 минут или позвоните:',
failed: 'Не получилось отправить. Попробуйте ещё раз или позвоните:',
// подписи для вёрстки (шаг 12)
formTag: 'Заявка',
errorTag: 'Не отправлено',
stamp: { created: 'Принято', clarified: 'Дописали' },
} as const;
export type CtaPreset = { label: string; interest: Interest; comment?: string };
+24
View File
@@ -14,6 +14,21 @@ export const site = {
ogDescription: 'Обжарка по четвергам, доставка по Минску в пятницу.',
},
// подписи для вёрстки (добавлены на шаге 12, нужны вычитка автора)
header: {
nav: [
{ href: '#coffee', label: 'Сорта' },
{ href: '#subscription', label: 'Подписка' },
{ href: '#delivery', label: 'Доставка' },
],
callLabel: 'Позвонить',
},
roastDate: {
label: 'Дата обжарки',
srPrefix: 'Ближайшая обжарка',
},
hero: {
stamp: 'ЧТ · обжарка',
eyebrow: 'Обжарка кофе в Минске',
@@ -32,11 +47,13 @@ export const site = {
{ day: '1–3 день', label: 'В чашке', caption: 'Открываете пачку и варите кофе.' },
],
why: 'Кофе вкуснее, пока обжарка свежая.',
weekLabel: 'Неделя обжарки',
},
catalog: {
title: 'Что обжариваем',
subtitle: 'Три сорта в пачках по 250 г и дрип-пакеты.',
label: { brand: 'Кварта · Минск', notes: 'Ноты', ribbon: 'Обжарено в чт' },
items: [
{
id: 'ethiopia',
@@ -91,6 +108,11 @@ export const site = {
price: 54,
priceDisplay: '54 BYN в месяц',
note: 'Подробности менеджер расскажет по телефону.',
fields: [
{ label: 'Состав', value: '2 пачки по 250 г' },
{ label: 'Как часто', value: 'раз в месяц' },
{ label: 'Доставка', value: 'бесплатно' },
],
cta: { label: 'Оформить подписку', interest: 'subscription', comment: 'Подписка: 2 пачки в месяц' } satisfies Cta,
},
@@ -124,6 +146,7 @@ export const site = {
{ days: 'Пн–пт', time: '8:00–20:00' },
{ days: 'Сб–вс', time: '10:00–18:00' },
],
map: { label: 'Открыть в Яндекс Картах', href: 'https://yandex.by/maps/?text=Минск%2C%20ул.%20Короля%2C%209' },
},
cta: { label: 'Задать вопрос', interest: 'question' } satisfies Cta,
},
@@ -131,6 +154,7 @@ export const site = {
lead: {
title: 'Оставьте заявку',
subtitle: 'Менеджер перезвонит в рабочее время и всё уточнит.',
call: 'Или позвоните:',
},
stickyCta: {
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { formatShortDate, getRoastWeek } from './roastWeek';
describe('getRoastWeek', () => {
it('в субботу ведёт на следующий четверг', () => {
const week = getRoastWeek(new Date('2026-10-17T12:00:00Z'));
expect(week.roast.iso).toBe('2026-10-22');
expect(week.delivery.iso).toBe('2026-10-23');
expect(week.days.map((d) => d.letter).join(' ')).toBe('ПН ВТ СР ЧТ ПТ СБ ВС');
expect(week.days[0]?.iso).toBe('2026-10-19');
expect(formatShortDate(week.roast)).toBe('22.10');
});
it('в четверг обжарка сегодня', () => {
expect(getRoastWeek(new Date('2026-10-22T08:00:00Z')).roast.iso).toBe('2026-10-22');
});
it('считает по Минску: ночь среды по UTC — уже четверг в Минске', () => {
expect(getRoastWeek(new Date('2026-10-21T22:30:00Z')).roast.iso).toBe('2026-10-22');
});
it('переход через месяц', () => {
const week = getRoastWeek(new Date('2026-10-30T10:00:00Z'));
expect(week.roast.iso).toBe('2026-11-05');
expect(formatShortDate(week.roast)).toBe('05.11');
});
});
+52
View File
@@ -0,0 +1,52 @@
// Неделя ближайшей обжарки по минскому времени: обжарка по четвергам, доставка в пятницу.
// Сегодня четверг — обжарка сегодня, иначе следующий четверг.
export type WeekDay = {
letter: string;
/** число месяца */
date: number;
/** YYYY-MM-DD */
iso: string;
};
export type RoastWeek = {
/** пн…вс недели обжарки */
days: WeekDay[];
roast: WeekDay;
delivery: WeekDay;
};
const LETTERS = ['ПН', 'ВТ', 'СР', 'ЧТ', 'ПТ', 'СБ', 'ВС'] as const;
const DAY_MS = 86_400_000;
/** Календарная дата в Минске как полночь UTC того же числа — удобно складывать дни. */
function minskDay(now: Date): Date {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Minsk',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(now);
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value);
return new Date(Date.UTC(get('year'), get('month') - 1, get('day')));
}
function toDay(d: Date, index: number): WeekDay {
return { letter: LETTERS[index] ?? '', date: d.getUTCDate(), iso: d.toISOString().slice(0, 10) };
}
export function getRoastWeek(now: Date = new Date()): RoastWeek {
const today = minskDay(now);
const weekday = (today.getUTCDay() + 6) % 7; // пн = 0
const untilThursday = (3 - weekday + 7) % 7;
const roast = new Date(today.getTime() + untilThursday * DAY_MS);
const monday = new Date(roast.getTime() - 3 * DAY_MS);
const day = (i: number) => toDay(new Date(monday.getTime() + i * DAY_MS), i);
return { days: LETTERS.map((_, i) => day(i)), roast: day(3), delivery: day(4) };
}
/** «22.10» */
export function formatShortDate(day: WeekDay): string {
const [, month, date] = day.iso.split('-');
return `${date}.${month}`;
}