diff --git a/apps/landing/src/app/page.tsx b/apps/landing/src/app/page.tsx
index 26612e2..de8d2d4 100644
--- a/apps/landing/src/app/page.tsx
+++ b/apps/landing/src/app/page.tsx
@@ -1,3 +1,31 @@
+// Заглушка страницы: проверяем форму и 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';
+
+const ctaClass = 'rounded border border-neutral-900 px-4 py-2';
+
export default function Page() {
- return Кварта;
+ return (
+
+
+ Кварта — свежая обжарка в Минске
+ Обжариваем по четвергам, привозим по пятницам.
+
+ {Object.values(CTAS).map((cta) => (
+
+ {cta.label}
+
+ ))}
+
+
+
+
+ {FORM_TEXT.title}
+ {FORM_TEXT.lead}
+
+
+
+ );
}
diff --git a/apps/landing/src/components/CtaButton.tsx b/apps/landing/src/components/CtaButton.tsx
new file mode 100644
index 0000000..debea74
--- /dev/null
+++ b/apps/landing/src/components/CtaButton.tsx
@@ -0,0 +1,31 @@
+'use client';
+// Клиентский островок: кнопка CTA в серверной секции, предзаполняет форму заявки.
+import type { Interest } from '@kvarta/shared/lead';
+import type { ReactNode } from 'react';
+import { prefillLead } from '@/lib/leadPrefill';
+
+export type CtaButtonProps = {
+ interest: Interest;
+ /** комментарий для формы, например «Эфиопия Иргачеффе, 250 г» */
+ comment?: string;
+ /** id секции-источника; по умолчанию — ближайший предок с id */
+ source?: string;
+ className?: string;
+ children: ReactNode;
+};
+
+export function CtaButton({ interest, comment, source, className, children }: CtaButtonProps) {
+ return (
+
+ );
+}
diff --git a/apps/landing/src/components/LeadForm.tsx b/apps/landing/src/components/LeadForm.tsx
new file mode 100644
index 0000000..9db3f5c
--- /dev/null
+++ b/apps/landing/src/components/LeadForm.tsx
@@ -0,0 +1,155 @@
+'use client';
+// Разметка формы заявки. Вся логика — в useLeadForm; вёрстку переделает дизайнер.
+import { INTERESTS, INTEREST_LABELS, type LeadField } from '@kvarta/shared/lead';
+import { FORM_TEXT } from '@/content/form';
+import { LEAD_NAME_INPUT_ID } from '@/lib/leadPrefill';
+import type { SendLead } from '@/lib/sendLead';
+import { useLeadForm } from '@/lib/useLeadForm';
+
+export type LeadFormProps = {
+ /** подмена отправки — для тестов и витрины состояний */
+ sendLead?: SendLead;
+ className?: string;
+};
+
+const ids = {
+ name: LEAD_NAME_INPUT_ID,
+ phone: 'lead-phone',
+ interest: 'lead-interest',
+ comment: 'lead-comment',
+ company: 'lead-company',
+} as const;
+
+const errorId = (field: LeadField) => `${ids[field]}-error`;
+
+function capitalize(s: string) {
+ return s.charAt(0).toUpperCase() + s.slice(1);
+}
+
+export function LeadForm({ sendLead, className }: LeadFormProps) {
+ const form = useLeadForm({ sendLead });
+ const { values, errors, status } = form;
+
+ const fieldProps = (field: LeadField) => ({
+ 'aria-invalid': errors[field] ? true : undefined,
+ 'aria-describedby': errors[field] ? errorId(field) : undefined,
+ 'data-invalid': errors[field] ? '' : undefined,
+ });
+
+ const fieldError = (field: LeadField) =>
+ errors[field] ? (
+
+ {errors[field]}
+
+ ) : null;
+
+ return (
+
+ );
+}
diff --git a/apps/landing/src/content/form.ts b/apps/landing/src/content/form.ts
new file mode 100644
index 0000000..e17f890
--- /dev/null
+++ b/apps/landing/src/content/form.ts
@@ -0,0 +1,33 @@
+// Все тексты формы заявки и CTA в одном месте — для вычитки.
+import type { Interest } from '@kvarta/shared/lead';
+
+export const CONTACT_PHONE = {
+ display: '+375 29 000-00-00',
+ href: 'tel:+375290000000',
+} as const;
+
+export const FORM_TEXT = {
+ title: 'Оставьте заявку',
+ lead: 'Перезвоним в рабочее время и всё уточним.',
+ name: { label: 'Имя', placeholder: 'Как к вам обращаться' },
+ phone: { label: 'Телефон', placeholder: '+375 29 123-45-67' },
+ interest: { legend: 'Что вас интересует' },
+ comment: { label: 'Комментарий', placeholder: 'Сорт, помол, удобное время звонка — по желанию' },
+ honeypot: { label: 'Компания (не заполняйте)' },
+ submit: 'Отправить',
+ submitting: 'Отправляем…',
+ success: 'Спасибо! Перезвоним в рабочее время',
+ clarified: 'Дописали к вашей заявке',
+ sendAnother: 'Отправить ещё одну',
+ rateLimited: 'Слишком много попыток. Подождите немного',
+ failed: 'Не получилось отправить. Попробуйте ещё раз или позвоните:',
+ callUs: 'Позвоните нам:',
+} as const;
+
+export type CtaPreset = { label: string; interest: Interest; comment?: string };
+
+export const CTAS = {
+ subscription: { label: 'Оформить подписку', interest: 'subscription' },
+ ethiopia: { label: 'Заказать пачку Эфиопии', interest: 'one_time', comment: 'Эфиопия Иргачеффе, 250 г' },
+ question: { label: 'Задать вопрос', interest: 'question' },
+} as const satisfies Record;
diff --git a/apps/landing/src/lib/leadFormModel.ts b/apps/landing/src/lib/leadFormModel.ts
new file mode 100644
index 0000000..e6c0d34
--- /dev/null
+++ b/apps/landing/src/lib/leadFormModel.ts
@@ -0,0 +1,52 @@
+// Чистая модель формы заявки: значения, видимые ошибки, тело запроса.
+import { validateLeadFields, type FieldErrors, type Interest, type LeadField, type LeadRequest, type Touch } from '@kvarta/shared/lead';
+
+export type LeadFormValues = {
+ name: string;
+ phone: string;
+ interest: Interest | '';
+ comment: string;
+ /** honeypot */
+ company: string;
+};
+
+export const EMPTY_VALUES: LeadFormValues = { name: '', phone: '', interest: '', comment: '', company: '' };
+
+export type Touched = Partial>;
+
+/**
+ * Ошибки, которые пора показать: по полю — после blur или попытки отправки;
+ * ошибки сервера видны, пока посетитель не поправил поле.
+ */
+export function visibleErrors(
+ values: LeadFormValues,
+ touched: Touched,
+ submitAttempted: boolean,
+ serverErrors: FieldErrors,
+): FieldErrors {
+ const client = validateLeadFields(values);
+ const out: FieldErrors = {};
+ for (const field of ['name', 'phone', 'interest', 'comment'] as const) {
+ const message = submitAttempted || touched[field] ? client[field] : undefined;
+ const shown = message ?? serverErrors[field];
+ if (shown) out[field] = shown;
+ }
+ return out;
+}
+
+export function buildLeadRequest(
+ values: LeadFormValues,
+ touches: { firstTouch: Touch | null; lastTouch: Touch },
+): LeadRequest {
+ const body: LeadRequest = {
+ name: values.name.trim(),
+ phone: values.phone.trim(),
+ interest: values.interest as Interest,
+ firstTouch: touches.firstTouch,
+ lastTouch: touches.lastTouch,
+ };
+ const comment = values.comment.trim();
+ if (comment !== '') body.comment = comment;
+ if (values.company !== '') body.company = values.company;
+ return body;
+}
diff --git a/apps/landing/src/lib/leadPrefill.test.ts b/apps/landing/src/lib/leadPrefill.test.ts
new file mode 100644
index 0000000..4b1dbfe
--- /dev/null
+++ b/apps/landing/src/lib/leadPrefill.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+import { EMPTY_VALUES } from './leadFormModel';
+import { applyPrefill, getLeadPrefill, prefillLead, subscribeLeadPrefill } from './leadPrefill';
+
+const ethiopia = { interest: 'one_time', comment: 'Эфиопия Иргачеффе, 250 г' } as const;
+
+describe('applyPrefill', () => {
+ it('ставит интерес и комментарий в пустую форму', () => {
+ const r = applyPrefill(EMPTY_VALUES, ethiopia, null);
+ expect(r.values).toMatchObject({ interest: 'one_time', comment: 'Эфиопия Иргачеффе, 250 г' });
+ expect(r.prefilledComment).toBe('Эфиопия Иргачеффе, 250 г');
+ });
+
+ it('не затирает комментарий, который посетитель напечатал сам', () => {
+ const values = { ...EMPTY_VALUES, interest: 'question' as const, comment: 'Можно помолоть под турку?' };
+ const r = applyPrefill(values, ethiopia, null);
+ expect(r.values).toMatchObject({ interest: 'one_time', comment: 'Можно помолоть под турку?' });
+ });
+
+ it('заменяет свой прошлый предзаполненный комментарий', () => {
+ const first = applyPrefill(EMPTY_VALUES, ethiopia, null);
+ const second = applyPrefill(first.values, { interest: 'subscription' }, first.prefilledComment);
+ expect(second.values).toMatchObject({ interest: 'subscription', comment: '' });
+ expect(second.prefilledComment).toBeNull();
+ });
+
+ it('если посетитель дописал предзаполненный комментарий — он уже его', () => {
+ const first = applyPrefill(EMPTY_VALUES, ethiopia, null);
+ const edited = { ...first.values, comment: `${first.values.comment}, помол под эспрессо` };
+ const second = applyPrefill(edited, { interest: 'subscription' }, first.prefilledComment);
+ expect(second.values.comment).toBe('Эфиопия Иргачеффе, 250 г, помол под эспрессо');
+ });
+
+ it('пробелы в поле не считаются текстом посетителя', () => {
+ const r = applyPrefill({ ...EMPTY_VALUES, comment: ' ' }, ethiopia, null);
+ expect(r.values.comment).toBe('Эфиопия Иргачеффе, 250 г');
+ });
+});
+
+describe('prefillLead', () => {
+ it('уведомляет подписчиков и работает без DOM', () => {
+ const got: unknown[] = [];
+ const unsubscribe = subscribeLeadPrefill((p) => got.push(p));
+ prefillLead({ interest: 'question', source: 'hero' });
+ unsubscribe();
+ prefillLead({ interest: 'subscription' });
+ expect(got).toEqual([expect.objectContaining({ interest: 'question', source: 'hero' })]);
+ expect(getLeadPrefill()).toMatchObject({ interest: 'subscription' });
+ });
+});
diff --git a/apps/landing/src/lib/leadPrefill.ts b/apps/landing/src/lib/leadPrefill.ts
new file mode 100644
index 0000000..21ea726
--- /dev/null
+++ b/apps/landing/src/lib/leadPrefill.ts
@@ -0,0 +1,66 @@
+// Предзаполнение формы заявки из CTA в любой секции.
+// Маленький store вместо контекста: секции остаются серверными компонентами,
+// кнопки — клиентскими островками, форма подписывается на store сама.
+import type { Interest } from '@kvarta/shared/lead';
+
+export const LEAD_SECTION_ID = 'lead';
+export const LEAD_NAME_INPUT_ID = 'lead-name';
+
+export type LeadPrefillInput = {
+ interest: Interest;
+ comment?: string;
+ /** id секции, откуда пришла CTA, — пойдёт в submitPage последнего касания */
+ source?: string;
+};
+
+export type LeadPrefill = LeadPrefillInput & { seq: number };
+
+type Listener = (prefill: LeadPrefill) => void;
+
+let current: LeadPrefill | null = null;
+const listeners = new Set();
+
+export function getLeadPrefill(): LeadPrefill | null {
+ return current;
+}
+
+export function subscribeLeadPrefill(listener: Listener): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+/** Выставить значения формы, плавно доскроллить до #lead и поставить фокус в поле имени. */
+export function prefillLead(input: LeadPrefillInput): void {
+ current = { ...input, seq: (current?.seq ?? 0) + 1 };
+ for (const listener of listeners) listener(current);
+ if (typeof document === 'undefined') return;
+
+ const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
+ document
+ .getElementById(LEAD_SECTION_ID)
+ ?.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' });
+ // preventScroll — чтобы фокус не оборвал плавный скролл рывком к полю
+ document.getElementById(LEAD_NAME_INPUT_ID)?.focus({ preventScroll: true });
+}
+
+/**
+ * Применить предзаполнение к текущим значениям формы.
+ * Комментарий, который посетитель напечатал сам, не трогаем; свой прошлый предзаполненный — заменяем.
+ */
+export function applyPrefill(
+ values: T,
+ prefill: LeadPrefillInput,
+ lastPrefilledComment: string | null,
+): { values: T; prefilledComment: string | null } {
+ const typedByVisitor = values.comment.trim() !== '' && values.comment !== lastPrefilledComment;
+ if (typedByVisitor) {
+ return { values: { ...values, interest: prefill.interest }, prefilledComment: lastPrefilledComment };
+ }
+ const comment = prefill.comment ?? '';
+ return {
+ values: { ...values, interest: prefill.interest, comment },
+ prefilledComment: comment === '' ? null : comment,
+ };
+}
diff --git a/apps/landing/src/lib/sendLead.ts b/apps/landing/src/lib/sendLead.ts
new file mode 100644
index 0000000..c237e20
--- /dev/null
+++ b/apps/landing/src/lib/sendLead.ts
@@ -0,0 +1,27 @@
+// Отправка заявки на POST /api/lead.
+import type { FieldErrors, LeadRequest, LeadResponse } from '@kvarta/shared/lead';
+
+export type SubmitOutcome =
+ | { kind: 'success' }
+ | { kind: 'invalid'; fieldErrors: FieldErrors }
+ | { kind: 'failed' };
+
+export type SendLead = (body: LeadRequest) => Promise;
+
+export const sendLead: SendLead = async (body) => {
+ try {
+ const res = await fetch('/api/lead', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const data = (await res.json().catch(() => null)) as LeadResponse | null;
+ if (res.ok && data?.ok) return { kind: 'success' };
+ if (res.status === 400 && data && !data.ok && data.error === 'validation') {
+ return { kind: 'invalid', fieldErrors: data.fieldErrors };
+ }
+ return { kind: 'failed' };
+ } catch {
+ return { kind: 'failed' };
+ }
+};
diff --git a/apps/landing/src/lib/useLeadForm.ts b/apps/landing/src/lib/useLeadForm.ts
new file mode 100644
index 0000000..5a2fd21
--- /dev/null
+++ b/apps/landing/src/lib/useLeadForm.ts
@@ -0,0 +1,84 @@
+'use client';
+// Логика формы заявки без разметки: значения, ошибки, предзаполнение, отправка.
+import { formatBelarusPhone } from '@kvarta/shared/phone';
+import { validateLeadFields, type FieldErrors, type LeadField } from '@kvarta/shared/lead';
+import { useEffect, useRef, useState, type FormEvent } from 'react';
+import { EMPTY_VALUES, buildLeadRequest, visibleErrors, type LeadFormValues, type Touched } from './leadFormModel';
+import { applyPrefill, getLeadPrefill, subscribeLeadPrefill, type LeadPrefillInput } from './leadPrefill';
+import { sendLead as defaultSendLead, type SendLead, type SubmitOutcome } from './sendLead';
+
+export type LeadFormStatus = 'idle' | 'submitting' | 'success' | 'error';
+
+export function useLeadForm({ sendLead = defaultSendLead }: { sendLead?: SendLead } = {}) {
+ // prefilledComment живёт рядом со значениями: updater должен быть чистым (StrictMode вызывает его дважды)
+ const [form, setForm] = useState<{ values: LeadFormValues; prefilledComment: string | null }>(() => {
+ const prefill = getLeadPrefill();
+ return prefill ? applyPrefill(EMPTY_VALUES, prefill, null) : { values: EMPTY_VALUES, prefilledComment: null };
+ });
+ const values = form.values;
+ const setValues = (update: (prev: LeadFormValues) => LeadFormValues) =>
+ setForm((prev) => ({ ...prev, values: update(prev.values) }));
+ const [touched, setTouched] = useState({});
+ const [submitAttempted, setSubmitAttempted] = useState(false);
+ const [serverErrors, setServerErrors] = useState({});
+ const [status, setStatus] = useState('idle');
+ const source = useRef(getLeadPrefill()?.source);
+
+ useEffect(
+ () =>
+ subscribeLeadPrefill((prefill: LeadPrefillInput) => {
+ source.current = prefill.source;
+ setForm((prev) => applyPrefill(prev.values, prefill, prev.prefilledComment));
+ }),
+ [],
+ );
+
+ const errors = visibleErrors(values, touched, submitAttempted, serverErrors);
+
+ function setField(field: K, value: LeadFormValues[K]) {
+ setValues((prev) => ({ ...prev, [field]: value }));
+ if (field in serverErrors) {
+ setServerErrors((prev) => {
+ const next = { ...prev };
+ delete next[field as LeadField];
+ return next;
+ });
+ }
+ }
+
+ function blurField(field: LeadField) {
+ setTouched((prev) => ({ ...prev, [field]: true }));
+ if (field === 'phone') {
+ const formatted = formatBelarusPhone(values.phone);
+ if (formatted && formatted !== values.phone) setValues((prev) => ({ ...prev, phone: formatted }));
+ }
+ }
+
+ async function submit(event?: FormEvent) {
+ event?.preventDefault();
+ if (status === 'submitting') return;
+ setSubmitAttempted(true);
+ if (Object.keys(validateLeadFields(values)).length > 0) return;
+
+ setStatus('submitting');
+ const body = buildLeadRequest(values, {
+ firstTouch: null,
+ lastTouch: {
+ landingPage: window.location.pathname + window.location.search,
+ submitPage: window.location.pathname + (source.current ? `#${source.current}` : ''),
+ at: new Date().toISOString(),
+ },
+ });
+ const outcome: SubmitOutcome = await sendLead(body);
+ if (outcome.kind === 'invalid') {
+ setServerErrors(outcome.fieldErrors);
+ setStatus('error');
+ } else {
+ setStatus(outcome.kind === 'success' ? 'success' : 'error');
+ }
+ }
+
+ return { values, errors, status, setField, blurField, submit };
+}
+
+export type LeadFormController = ReturnType;