From 02da4837479e996510483466fabf2c69a0793a11 Mon Sep 17 00:00:00 2001 From: Nikolay Unuchek Date: Sat, 17 Oct 2026 15:40:25 +0300 Subject: [PATCH] =?UTF-8?q?feat(landing):=20=D0=B7=D0=B0=D0=B3=D0=BB=D1=83?= =?UTF-8?q?=D1=88=D0=BA=D0=B0=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D1=8B=20=D1=81=20=D1=80=D0=B0=D0=B1=D0=BE=D1=87=D0=B5=D0=B9=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/landing/src/app/page.tsx | 30 ++++- apps/landing/src/components/CtaButton.tsx | 31 +++++ apps/landing/src/components/LeadForm.tsx | 155 ++++++++++++++++++++++ apps/landing/src/content/form.ts | 33 +++++ apps/landing/src/lib/leadFormModel.ts | 52 ++++++++ apps/landing/src/lib/leadPrefill.test.ts | 50 +++++++ apps/landing/src/lib/leadPrefill.ts | 66 +++++++++ apps/landing/src/lib/sendLead.ts | 27 ++++ apps/landing/src/lib/useLeadForm.ts | 84 ++++++++++++ 9 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 apps/landing/src/components/CtaButton.tsx create mode 100644 apps/landing/src/components/LeadForm.tsx create mode 100644 apps/landing/src/content/form.ts create mode 100644 apps/landing/src/lib/leadFormModel.ts create mode 100644 apps/landing/src/lib/leadPrefill.test.ts create mode 100644 apps/landing/src/lib/leadPrefill.ts create mode 100644 apps/landing/src/lib/sendLead.ts create mode 100644 apps/landing/src/lib/useLeadForm.ts 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 ( +
+
+
+ + form.setField('name', e.target.value)} + onBlur={() => form.blurField('name')} + {...fieldProps('name')} + /> + {fieldError('name')} +
+ +
+ + form.setField('phone', e.target.value)} + onBlur={() => form.blurField('phone')} + {...fieldProps('phone')} + /> + {fieldError('phone')} +
+ +
+ {FORM_TEXT.interest.legend} +
+ {INTERESTS.map((interest) => ( + + ))} +
+ {fieldError('interest')} +
+ +
+ +