feat(landing): состояния формы и ошибки отправки

This commit is contained in:
2026-10-17 15:47:24 +03:00
parent a5ee348df0
commit e6610f0408
5 changed files with 381 additions and 140 deletions
+79 -17
View File
@@ -1,9 +1,11 @@
'use client'; 'use client';
// Разметка формы заявки. Вся логика — в useLeadForm; вёрстку переделает дизайнер. // Разметка формы заявки. Вся логика — в useLeadForm; вёрстку переделает дизайнер.
// Состояния для стилей: data-state (idle|submitting|success|error), data-notice, data-invalid, data-checked.
import { INTERESTS, INTEREST_LABELS, type LeadField } from '@kvarta/shared/lead'; import { INTERESTS, INTEREST_LABELS, type LeadField } from '@kvarta/shared/lead';
import { FORM_TEXT } from '@/content/form'; import { useEffect, useRef } from 'react';
import { CONTACT_PHONE, FORM_TEXT } from '@/content/form';
import { LEAD_NAME_INPUT_ID } from '@/lib/leadPrefill'; import { LEAD_NAME_INPUT_ID } from '@/lib/leadPrefill';
import type { SendLead } from '@/lib/sendLead'; import type { SendLead, SubmitNotice } from '@/lib/sendLead';
import { useLeadForm } from '@/lib/useLeadForm'; import { useLeadForm } from '@/lib/useLeadForm';
export type LeadFormProps = { export type LeadFormProps = {
@@ -26,9 +28,51 @@ function capitalize(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1); return s.charAt(0).toUpperCase() + s.slice(1);
} }
function PhoneLink() {
return (
<a href={CONTACT_PHONE.href} className="whitespace-nowrap underline">
{CONTACT_PHONE.display}
</a>
);
}
function Notice({ notice }: { notice: SubmitNotice | null }) {
switch (notice) {
case 'created':
return <p>{FORM_TEXT.success}</p>;
case 'clarified':
return <p>{FORM_TEXT.clarified}</p>;
case 'rate_limited':
return (
<div className="flex flex-col gap-1">
<p>{FORM_TEXT.rateLimited}</p>
<p>
{FORM_TEXT.callUs} <PhoneLink />
</p>
</div>
);
case 'failed':
return (
<p>
{FORM_TEXT.failed} <PhoneLink />
</p>
);
default:
// invalid — подсказки уже под полями
return null;
}
}
export function LeadForm({ sendLead, className }: LeadFormProps) { export function LeadForm({ sendLead, className }: LeadFormProps) {
const form = useLeadForm({ sendLead }); const form = useLeadForm({ sendLead });
const { values, errors, status } = form; const { values, errors, status, notice } = form;
const submitting = status === 'submitting';
const sendAnotherRef = useRef<HTMLButtonElement>(null);
// кнопка отправки исчезает вместе с формой — переносим фокус, чтобы он не потерялся
useEffect(() => {
if (status === 'success') sendAnotherRef.current?.focus();
}, [status]);
const fieldProps = (field: LeadField) => ({ const fieldProps = (field: LeadField) => ({
'aria-invalid': errors[field] ? true : undefined, 'aria-invalid': errors[field] ? true : undefined,
@@ -44,8 +88,9 @@ export function LeadForm({ sendLead, className }: LeadFormProps) {
) : null; ) : null;
return ( return (
<form noValidate onSubmit={form.submit} className={`relative ${className ?? ''}`} data-state={status}> <div className={`relative flex flex-col gap-4 ${className ?? ''}`} data-state={status} data-notice={notice ?? undefined}>
<div className="flex flex-col gap-4"> {status !== 'success' && (
<form noValidate onSubmit={form.submit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor={ids.name}>{FORM_TEXT.name.label}</label> <label htmlFor={ids.name}>{FORM_TEXT.name.label}</label>
<input <input
@@ -84,14 +129,16 @@ export function LeadForm({ sendLead, className }: LeadFormProps) {
<fieldset <fieldset
role="radiogroup" role="radiogroup"
className="flex flex-col gap-1" className="flex flex-col gap-1"
aria-invalid={errors.interest ? true : undefined} {...fieldProps('interest')}
aria-describedby={errors.interest ? errorId('interest') : undefined}
data-invalid={errors.interest ? '' : undefined}
> >
<legend>{FORM_TEXT.interest.legend}</legend> <legend>{FORM_TEXT.interest.legend}</legend>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
{INTERESTS.map((interest) => ( {INTERESTS.map((interest) => (
<label key={interest} className="flex items-center gap-2" data-checked={values.interest === interest ? '' : undefined}> <label
key={interest}
className="flex items-center gap-2"
data-checked={values.interest === interest ? '' : undefined}
>
<input <input
type="radio" type="radio"
name="interest" name="interest"
@@ -140,16 +187,31 @@ export function LeadForm({ sendLead, className }: LeadFormProps) {
<button <button
type="submit" type="submit"
className="self-start rounded bg-neutral-900 px-5 py-2 text-white disabled:opacity-60" className="self-start rounded bg-neutral-900 px-5 py-2 text-white disabled:opacity-60"
disabled={status === 'submitting'} disabled={submitting}
aria-busy={submitting || undefined}
> >
{status === 'submitting' ? FORM_TEXT.submitting : FORM_TEXT.submit} {submitting ? FORM_TEXT.submitting : FORM_TEXT.submit}
</button> </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> </form>
)}
<div aria-live="polite" data-lead-notice="">
<Notice notice={notice} />
</div>
{status === 'success' && (
<button
ref={sendAnotherRef}
type="button"
className="self-start rounded border border-neutral-900 px-5 py-2"
onClick={() => {
form.reset();
requestAnimationFrame(() => document.getElementById(LEAD_NAME_INPUT_ID)?.focus());
}}
>
{FORM_TEXT.sendAnother}
</button>
)}
</div>
); );
} }
+2 -1
View File
@@ -41,8 +41,9 @@ export function prefillLead(input: LeadPrefillInput): void {
document document
.getElementById(LEAD_SECTION_ID) .getElementById(LEAD_SECTION_ID)
?.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' }); ?.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' });
// кадр спустя: после подтверждения форма появляется заново и поля имени ещё нет в DOM;
// preventScroll — чтобы фокус не оборвал плавный скролл рывком к полю // preventScroll — чтобы фокус не оборвал плавный скролл рывком к полю
document.getElementById(LEAD_NAME_INPUT_ID)?.focus({ preventScroll: true }); requestAnimationFrame(() => document.getElementById(LEAD_NAME_INPUT_ID)?.focus({ preventScroll: true }));
} }
/** /**
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { LeadRequest } from '@kvarta/shared/lead';
import { createSendLead, resultFromResponse } from './sendLead';
describe('resultFromResponse', () => {
it.each([
[201, { ok: true, duplicate: false }, 'success', 'created'],
[200, { ok: true, duplicate: true, clarified: false }, 'success', 'created'],
[200, { ok: true, duplicate: true, clarified: true }, 'success', 'clarified'],
[429, { ok: false, error: 'rate_limited' }, 'error', 'rate_limited'],
[413, { ok: false, error: 'bad_request' }, 'error', 'failed'],
[415, { ok: false, error: 'bad_request' }, 'error', 'failed'],
[500, { ok: false, error: 'server' }, 'error', 'failed'],
[502, null, 'error', 'failed'],
[503, { ok: false, error: 'unavailable' }, 'error', 'failed'],
[404, '<html>', 'error', 'failed'],
])('%i %j → %s / %s', (status, body, expectedStatus, notice) => {
expect(resultFromResponse(status, body)).toMatchObject({ status: expectedStatus, notice });
});
it('201 без тела — всё равно успех: заявка создана', () => {
expect(resultFromResponse(201, null)).toMatchObject({ status: 'success', notice: 'created' });
});
it('200 без ok — не успех', () => {
expect(resultFromResponse(200, null).status).toBe('error');
});
it('400 → подсказки под полями, лишнее отбрасывается', () => {
const result = resultFromResponse(400, {
ok: false,
error: 'validation',
fieldErrors: { phone: 'После кода нужно 7 цифр', evil: 'x', name: 42 },
});
expect(result).toEqual({ status: 'error', notice: 'invalid', fieldErrors: { phone: 'После кода нужно 7 цифр' } });
});
it('400 без подсказок — общий блок «позвоните»', () => {
expect(resultFromResponse(400, { ok: false })).toMatchObject({ notice: 'failed', fieldErrors: {} });
});
});
describe('createSendLead', () => {
const body: LeadRequest = {
name: 'Анна',
phone: '+375 29 123-45-67',
interest: 'subscription',
lastTouch: { landingPage: '/', at: '2026-10-17T10:00:00.000Z' },
};
afterEach(() => {
vi.useRealTimers();
});
it('шлёт JSON на /api/lead и разбирает ответ', async () => {
const fetchImpl = vi.fn(async () => Response.json({ ok: true, duplicate: true, clarified: true }, { status: 200 }));
const result = await createSendLead({ fetchImpl })(body);
expect(result.notice).toBe('clarified');
const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe('/api/lead');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body as string)).toEqual(body);
});
it('сеть недоступна → failed', async () => {
const fetchImpl = vi.fn(async () => {
throw new TypeError('Failed to fetch');
});
expect((await createSendLead({ fetchImpl })(body)).notice).toBe('failed');
});
it('ответ не JSON → по коду', async () => {
const fetchImpl = vi.fn(async () => new Response('Bad Gateway', { status: 502 }));
expect((await createSendLead({ fetchImpl })(body)).notice).toBe('failed');
});
it('таймаут 10 с обрывает запрос → failed', async () => {
vi.useFakeTimers();
let signal: AbortSignal | undefined;
const fetchImpl = vi.fn(
(_url: string | URL | Request, init?: RequestInit) =>
new Promise<Response>((_, reject) => {
signal = init?.signal ?? undefined;
signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
}),
);
const pending = createSendLead({ fetchImpl })(body);
await vi.advanceTimersByTimeAsync(9_999);
expect(signal?.aborted).toBe(false);
await vi.advanceTimersByTimeAsync(1);
expect(signal?.aborted).toBe(true);
expect((await pending).notice).toBe('failed');
});
});
+69 -16
View File
@@ -1,27 +1,80 @@
// Отправка заявки на POST /api/lead. // Отправка заявки на POST /api/lead и перевод ответа сервера в состояние формы.
import type { FieldErrors, LeadRequest, LeadResponse } from '@kvarta/shared/lead'; import type { FieldErrors, LeadField, LeadRequest } from '@kvarta/shared/lead';
export type SubmitOutcome = export const SUBMIT_TIMEOUT_MS = 10_000;
| { kind: 'success' }
| { kind: 'invalid'; fieldErrors: FieldErrors }
| { kind: 'failed' };
export type SendLead = (body: LeadRequest) => Promise<SubmitOutcome>; /** Что сказать посетителю после отправки. */
export type SubmitNotice = 'created' | 'clarified' | 'invalid' | 'rate_limited' | 'failed';
export const sendLead: SendLead = async (body) => { export type SubmitResult = {
status: 'success' | 'error';
notice: SubmitNotice;
/** подсказки под полями при 400 */
fieldErrors: FieldErrors;
};
export type SendLead = (body: LeadRequest) => Promise<SubmitResult>;
const FIELDS: readonly LeadField[] = ['name', 'phone', 'interest', 'comment'];
const success = (notice: 'created' | 'clarified'): SubmitResult => ({ status: 'success', notice, fieldErrors: {} });
const error = (notice: SubmitNotice, fieldErrors: FieldErrors = {}): SubmitResult => ({
status: 'error',
notice,
fieldErrors,
});
/** Сеть, таймаут, отказ fetch. */
export const FAILED: SubmitResult = error('failed');
function pickFieldErrors(value: unknown): FieldErrors {
if (!value || typeof value !== 'object') return {};
const src = value as Record<string, unknown>;
const out: FieldErrors = {};
for (const field of FIELDS) {
const message = src[field];
if (typeof message === 'string' && message !== '') out[field] = message;
}
return out;
}
/** Ответ сервера → состояние формы. Тело может быть чем угодно, в том числе null. */
export function resultFromResponse(status: number, body: unknown): SubmitResult {
const data = (body && typeof body === 'object' ? body : {}) as Record<string, unknown>;
if (status === 201) return success('created');
if (status === 200 && data.ok === true) return success(data.clarified === true ? 'clarified' : 'created');
if (status === 400) {
const fieldErrors = pickFieldErrors(data.fieldErrors);
return Object.keys(fieldErrors).length > 0 ? error('invalid', fieldErrors) : FAILED;
}
if (status === 429) return error('rate_limited');
// 413, 415, 5xx, 503 и всё неожиданное — «попробуйте ещё раз или позвоните»
return FAILED;
}
export function createSendLead({
fetchImpl = (...args: Parameters<typeof fetch>) => fetch(...args),
timeoutMs = SUBMIT_TIMEOUT_MS,
url = '/api/lead',
}: { fetchImpl?: typeof fetch; timeoutMs?: number; url?: string } = {}): SendLead {
return async (body) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const res = await fetch('/api/lead', { const res = await fetchImpl(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
signal: controller.signal,
}); });
const data = (await res.json().catch(() => null)) as LeadResponse | null; const data: unknown = await res.json().catch(() => null);
if (res.ok && data?.ok) return { kind: 'success' }; return resultFromResponse(res.status, data);
if (res.status === 400 && data && !data.ok && data.error === 'validation') {
return { kind: 'invalid', fieldErrors: data.fieldErrors };
}
return { kind: 'failed' };
} catch { } catch {
return { kind: 'failed' }; return FAILED;
} finally {
clearTimeout(timer);
} }
}; };
}
export const sendLead: SendLead = createSendLead();
+43 -12
View File
@@ -1,18 +1,20 @@
'use client'; 'use client';
// Логика формы заявки без разметки: значения, ошибки, предзаполнение, отправка. // Логика формы заявки без разметки: значения, ошибки, предзаполнение, отправка, итог.
import { formatBelarusPhone } from '@kvarta/shared/phone'; import { formatBelarusPhone } from '@kvarta/shared/phone';
import { validateLeadFields, type FieldErrors, type LeadField } from '@kvarta/shared/lead'; import { validateLeadFields, type FieldErrors, type LeadField } from '@kvarta/shared/lead';
import { useEffect, useRef, useState, type FormEvent } from 'react'; import { useEffect, useRef, useState, type FormEvent } from 'react';
import { getBrowserTouches } from './attribution'; import { getBrowserTouches } from './attribution';
import { EMPTY_VALUES, buildLeadRequest, visibleErrors, type LeadFormValues, type Touched } from './leadFormModel'; import { EMPTY_VALUES, buildLeadRequest, visibleErrors, type LeadFormValues, type Touched } from './leadFormModel';
import { applyPrefill, getLeadPrefill, subscribeLeadPrefill, type LeadPrefillInput } from './leadPrefill'; import { applyPrefill, getLeadPrefill, subscribeLeadPrefill, type LeadPrefillInput } from './leadPrefill';
import { sendLead as defaultSendLead, type SendLead, type SubmitOutcome } from './sendLead'; import { FAILED, sendLead as defaultSendLead, type SendLead, type SubmitNotice } from './sendLead';
export type LeadFormStatus = 'idle' | 'submitting' | 'success' | 'error'; export type LeadFormStatus = 'idle' | 'submitting' | 'success' | 'error';
type FormState = { values: LeadFormValues; prefilledComment: string | null };
export function useLeadForm({ sendLead = defaultSendLead }: { sendLead?: SendLead } = {}) { export function useLeadForm({ sendLead = defaultSendLead }: { sendLead?: SendLead } = {}) {
// prefilledComment живёт рядом со значениями: updater должен быть чистым (StrictMode вызывает его дважды) // prefilledComment живёт рядом со значениями: updater должен быть чистым (StrictMode вызывает его дважды)
const [form, setForm] = useState<{ values: LeadFormValues; prefilledComment: string | null }>(() => { const [form, setForm] = useState<FormState>(() => {
const prefill = getLeadPrefill(); const prefill = getLeadPrefill();
return prefill ? applyPrefill(EMPTY_VALUES, prefill, null) : { values: EMPTY_VALUES, prefilledComment: null }; return prefill ? applyPrefill(EMPTY_VALUES, prefill, null) : { values: EMPTY_VALUES, prefilledComment: null };
}); });
@@ -23,13 +25,19 @@ export function useLeadForm({ sendLead = defaultSendLead }: { sendLead?: SendLea
const [submitAttempted, setSubmitAttempted] = useState(false); const [submitAttempted, setSubmitAttempted] = useState(false);
const [serverErrors, setServerErrors] = useState<FieldErrors>({}); const [serverErrors, setServerErrors] = useState<FieldErrors>({});
const [status, setStatus] = useState<LeadFormStatus>('idle'); const [status, setStatus] = useState<LeadFormStatus>('idle');
const [notice, setNotice] = useState<SubmitNotice | null>(null);
const source = useRef<string | undefined>(getLeadPrefill()?.source); const source = useRef<string | undefined>(getLeadPrefill()?.source);
// state обновится только после рендера, а второй клик/Enter может прийти раньше
const inFlight = useRef(false);
useEffect( useEffect(
() => () =>
subscribeLeadPrefill((prefill: LeadPrefillInput) => { subscribeLeadPrefill((prefill: LeadPrefillInput) => {
source.current = prefill.source; source.current = prefill.source;
setForm((prev) => applyPrefill(prev.values, prefill, prev.prefilledComment)); setForm((prev) => applyPrefill(prev.values, prefill, prev.prefilledComment));
// CTA после подтверждения — посетитель хочет новую заявку: возвращаем форму
setStatus((prev) => (prev === 'success' ? 'idle' : prev));
setNotice((prev) => (prev === 'created' || prev === 'clarified' ? null : prev));
}), }),
[], [],
); );
@@ -57,22 +65,45 @@ export function useLeadForm({ sendLead = defaultSendLead }: { sendLead?: SendLea
async function submit(event?: FormEvent) { async function submit(event?: FormEvent) {
event?.preventDefault(); event?.preventDefault();
if (status === 'submitting') return; if (inFlight.current) return;
setSubmitAttempted(true); setSubmitAttempted(true);
if (Object.keys(validateLeadFields(values)).length > 0) return; if (Object.keys(validateLeadFields(values)).length > 0) return;
inFlight.current = true;
setStatus('submitting'); setStatus('submitting');
const body = buildLeadRequest(values, getBrowserTouches(source.current)); setNotice(null);
const outcome: SubmitOutcome = await sendLead(body); try {
if (outcome.kind === 'invalid') { // любой сбой, даже синхронный при сборке тела, — это «не получилось отправить», а не зависшая кнопка
setServerErrors(outcome.fieldErrors); const result = await Promise.resolve()
setStatus('error'); .then(() => sendLead(buildLeadRequest(values, getBrowserTouches(source.current))))
} else { .catch(() => FAILED);
setStatus(outcome.kind === 'success' ? 'success' : 'error'); setServerErrors(result.fieldErrors);
setNotice(result.notice);
setStatus(result.status);
if (result.status === 'success') clearForNextLead();
} finally {
inFlight.current = false;
} }
} }
return { values, errors, status, setField, blurField, submit }; /** После успеха следующая заявка — с чистого листа; имя и телефон оставляем. */
function clearForNextLead() {
setForm((prev) => ({
values: { ...EMPTY_VALUES, name: prev.values.name, phone: prev.values.phone },
prefilledComment: null,
}));
setTouched({});
setSubmitAttempted(false);
source.current = undefined;
}
/** «Отправить ещё одну»: снова показать форму. */
function reset() {
setNotice(null);
setStatus('idle');
}
return { values, errors, status, notice, setField, blurField, submit, reset };
} }
export type LeadFormController = ReturnType<typeof useLeadForm>; export type LeadFormController = ReturnType<typeof useLeadForm>;