feat(landing): состояния формы и ошибки отправки
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
// Разметка формы заявки. Вся логика — в useLeadForm; вёрстку переделает дизайнер.
|
||||
// Состояния для стилей: data-state (idle|submitting|success|error), data-notice, data-invalid, data-checked.
|
||||
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 type { SendLead } from '@/lib/sendLead';
|
||||
import type { SendLead, SubmitNotice } from '@/lib/sendLead';
|
||||
import { useLeadForm } from '@/lib/useLeadForm';
|
||||
|
||||
export type LeadFormProps = {
|
||||
@@ -26,9 +28,51 @@ function capitalize(s: string) {
|
||||
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) {
|
||||
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) => ({
|
||||
'aria-invalid': errors[field] ? true : undefined,
|
||||
@@ -44,8 +88,9 @@ export function LeadForm({ sendLead, className }: LeadFormProps) {
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<form noValidate onSubmit={form.submit} className={`relative ${className ?? ''}`} data-state={status}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className={`relative flex flex-col gap-4 ${className ?? ''}`} data-state={status} data-notice={notice ?? undefined}>
|
||||
{status !== 'success' && (
|
||||
<form noValidate onSubmit={form.submit} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor={ids.name}>{FORM_TEXT.name.label}</label>
|
||||
<input
|
||||
@@ -84,14 +129,16 @@ export function LeadForm({ sendLead, className }: LeadFormProps) {
|
||||
<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}
|
||||
{...fieldProps('interest')}
|
||||
>
|
||||
<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}>
|
||||
<label
|
||||
key={interest}
|
||||
className="flex items-center gap-2"
|
||||
data-checked={values.interest === interest ? '' : undefined}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="interest"
|
||||
@@ -140,16 +187,31 @@ export function LeadForm({ sendLead, className }: LeadFormProps) {
|
||||
<button
|
||||
type="submit"
|
||||
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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,8 +41,9 @@ export function prefillLead(input: LeadPrefillInput): void {
|
||||
document
|
||||
.getElementById(LEAD_SECTION_ID)
|
||||
?.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' });
|
||||
// кадр спустя: после подтверждения форма появляется заново и поля имени ещё нет в DOM;
|
||||
// preventScroll — чтобы фокус не оборвал плавный скролл рывком к полю
|
||||
document.getElementById(LEAD_NAME_INPUT_ID)?.focus({ preventScroll: true });
|
||||
requestAnimationFrame(() => document.getElementById(LEAD_NAME_INPUT_ID)?.focus({ preventScroll: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,80 @@
|
||||
// Отправка заявки на POST /api/lead.
|
||||
import type { FieldErrors, LeadRequest, LeadResponse } from '@kvarta/shared/lead';
|
||||
// Отправка заявки на POST /api/lead и перевод ответа сервера в состояние формы.
|
||||
import type { FieldErrors, LeadField, LeadRequest } from '@kvarta/shared/lead';
|
||||
|
||||
export type SubmitOutcome =
|
||||
| { kind: 'success' }
|
||||
| { kind: 'invalid'; fieldErrors: FieldErrors }
|
||||
| { kind: 'failed' };
|
||||
export const SUBMIT_TIMEOUT_MS = 10_000;
|
||||
|
||||
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 {
|
||||
const res = await fetch('/api/lead', {
|
||||
const res = await fetchImpl(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
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' };
|
||||
const data: unknown = await res.json().catch(() => null);
|
||||
return resultFromResponse(res.status, data);
|
||||
} catch {
|
||||
return { kind: 'failed' };
|
||||
return FAILED;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const sendLead: SendLead = createSendLead();
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
'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 { getBrowserTouches } from './attribution';
|
||||
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';
|
||||
import { FAILED, sendLead as defaultSendLead, type SendLead, type SubmitNotice } from './sendLead';
|
||||
|
||||
export type LeadFormStatus = 'idle' | 'submitting' | 'success' | 'error';
|
||||
|
||||
type FormState = { values: LeadFormValues; prefilledComment: string | null };
|
||||
|
||||
export function useLeadForm({ sendLead = defaultSendLead }: { sendLead?: SendLead } = {}) {
|
||||
// prefilledComment живёт рядом со значениями: updater должен быть чистым (StrictMode вызывает его дважды)
|
||||
const [form, setForm] = useState<{ values: LeadFormValues; prefilledComment: string | null }>(() => {
|
||||
const [form, setForm] = useState<FormState>(() => {
|
||||
const prefill = getLeadPrefill();
|
||||
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 [serverErrors, setServerErrors] = useState<FieldErrors>({});
|
||||
const [status, setStatus] = useState<LeadFormStatus>('idle');
|
||||
const [notice, setNotice] = useState<SubmitNotice | null>(null);
|
||||
const source = useRef<string | undefined>(getLeadPrefill()?.source);
|
||||
// state обновится только после рендера, а второй клик/Enter может прийти раньше
|
||||
const inFlight = useRef(false);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeLeadPrefill((prefill: LeadPrefillInput) => {
|
||||
source.current = prefill.source;
|
||||
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) {
|
||||
event?.preventDefault();
|
||||
if (status === 'submitting') return;
|
||||
if (inFlight.current) return;
|
||||
setSubmitAttempted(true);
|
||||
if (Object.keys(validateLeadFields(values)).length > 0) return;
|
||||
|
||||
inFlight.current = true;
|
||||
setStatus('submitting');
|
||||
const body = buildLeadRequest(values, getBrowserTouches(source.current));
|
||||
const outcome: SubmitOutcome = await sendLead(body);
|
||||
if (outcome.kind === 'invalid') {
|
||||
setServerErrors(outcome.fieldErrors);
|
||||
setStatus('error');
|
||||
} else {
|
||||
setStatus(outcome.kind === 'success' ? 'success' : 'error');
|
||||
setNotice(null);
|
||||
try {
|
||||
// любой сбой, даже синхронный при сборке тела, — это «не получилось отправить», а не зависшая кнопка
|
||||
const result = await Promise.resolve()
|
||||
.then(() => sendLead(buildLeadRequest(values, getBrowserTouches(source.current))))
|
||||
.catch(() => FAILED);
|
||||
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>;
|
||||
|
||||
Reference in New Issue
Block a user