feat(landing): заглушка страницы с рабочей формой
This commit is contained in:
@@ -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() {
|
export default function Page() {
|
||||||
return <main>Кварта</main>;
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={className}
|
||||||
|
data-interest={interest}
|
||||||
|
onClick={(e) => {
|
||||||
|
const from = source ?? e.currentTarget.parentElement?.closest('[id]')?.id;
|
||||||
|
prefillLead({ interest, comment, source: from || undefined });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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] ? (
|
||||||
|
<p id={errorId(field)} className="text-sm text-red-700" data-field-error={field}>
|
||||||
|
{errors[field]}
|
||||||
|
</p>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form noValidate onSubmit={form.submit} className={`relative ${className ?? ''}`} data-state={status}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<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"
|
||||||
|
value={values.name}
|
||||||
|
onChange={(e) => form.setField('name', e.target.value)}
|
||||||
|
onBlur={() => form.blurField('name')}
|
||||||
|
{...fieldProps('name')}
|
||||||
|
/>
|
||||||
|
{fieldError('name')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor={ids.phone}>{FORM_TEXT.phone.label}</label>
|
||||||
|
<input
|
||||||
|
id={ids.phone}
|
||||||
|
name="phone"
|
||||||
|
type="tel"
|
||||||
|
inputMode="tel"
|
||||||
|
autoComplete="tel"
|
||||||
|
placeholder={FORM_TEXT.phone.placeholder}
|
||||||
|
className="rounded border border-neutral-400 px-3 py-2"
|
||||||
|
value={values.phone}
|
||||||
|
onChange={(e) => form.setField('phone', e.target.value)}
|
||||||
|
onBlur={() => form.blurField('phone')}
|
||||||
|
{...fieldProps('phone')}
|
||||||
|
/>
|
||||||
|
{fieldError('phone')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset
|
||||||
|
role="radiogroup"
|
||||||
|
className="flex flex-col gap-1"
|
||||||
|
aria-invalid={errors.interest ? true : undefined}
|
||||||
|
aria-describedby={errors.interest ? errorId('interest') : undefined}
|
||||||
|
data-invalid={errors.interest ? '' : undefined}
|
||||||
|
>
|
||||||
|
<legend>{FORM_TEXT.interest.legend}</legend>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{INTERESTS.map((interest) => (
|
||||||
|
<label key={interest} className="flex items-center gap-2" data-checked={values.interest === interest ? '' : undefined}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="interest"
|
||||||
|
value={interest}
|
||||||
|
checked={values.interest === interest}
|
||||||
|
onChange={() => form.setField('interest', interest)}
|
||||||
|
onBlur={() => form.blurField('interest')}
|
||||||
|
/>
|
||||||
|
{capitalize(INTEREST_LABELS[interest])}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{fieldError('interest')}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<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"
|
||||||
|
value={values.comment}
|
||||||
|
onChange={(e) => form.setField('comment', e.target.value)}
|
||||||
|
onBlur={() => form.blurField('comment')}
|
||||||
|
{...fieldProps('comment')}
|
||||||
|
/>
|
||||||
|
{fieldError('comment')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* honeypot: человек его не видит и не попадает в него с клавиатуры */}
|
||||||
|
<div aria-hidden="true" className="absolute -left-[10000px] h-px w-px overflow-hidden">
|
||||||
|
<label htmlFor={ids.company}>{FORM_TEXT.honeypot.label}</label>
|
||||||
|
<input
|
||||||
|
id={ids.company}
|
||||||
|
name="company"
|
||||||
|
type="text"
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
value={values.company}
|
||||||
|
onChange={(e) => form.setField('company', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="self-start rounded bg-neutral-900 px-5 py-2 text-white disabled:opacity-60"
|
||||||
|
disabled={status === 'submitting'}
|
||||||
|
>
|
||||||
|
{status === 'submitting' ? FORM_TEXT.submitting : FORM_TEXT.submit}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div aria-live="polite" data-result={status}>
|
||||||
|
{status === 'success' && <p>{FORM_TEXT.success}</p>}
|
||||||
|
{status === 'error' && Object.keys(errors).length === 0 && <p>{FORM_TEXT.failed}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, CtaPreset>;
|
||||||
@@ -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<Record<LeadField, boolean>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ошибки, которые пора показать: по полю — после 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;
|
||||||
|
}
|
||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Listener>();
|
||||||
|
|
||||||
|
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<T extends { interest: Interest | ''; comment: string }>(
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<SubmitOutcome>;
|
||||||
|
|
||||||
|
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' };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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<Touched>({});
|
||||||
|
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||||
|
const [serverErrors, setServerErrors] = useState<FieldErrors>({});
|
||||||
|
const [status, setStatus] = useState<LeadFormStatus>('idle');
|
||||||
|
const source = useRef<string | undefined>(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<K extends keyof LeadFormValues>(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<typeof useLeadForm>;
|
||||||
Reference in New Issue
Block a user