feat(landing): заглушка страницы с рабочей формой

This commit is contained in:
2026-10-17 15:47:24 +03:00
parent c2786f5894
commit 02da483747
9 changed files with 527 additions and 1 deletions
+52
View File
@@ -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;
}
+50
View File
@@ -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' });
});
});
+66
View File
@@ -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,
};
}
+27
View File
@@ -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' };
}
};
+84
View File
@@ -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>;