- {INTERESTS.map((interest) => (
-
- ))}
+
+ {status !== 'success' && (
+
+ )}
+
+
+
-
+
+ {status === 'success' && (
+
+ )}
+
);
}
diff --git a/apps/landing/src/lib/leadPrefill.ts b/apps/landing/src/lib/leadPrefill.ts
index 21ea726..dbe0a62 100644
--- a/apps/landing/src/lib/leadPrefill.ts
+++ b/apps/landing/src/lib/leadPrefill.ts
@@ -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 }));
}
/**
diff --git a/apps/landing/src/lib/sendLead.test.ts b/apps/landing/src/lib/sendLead.test.ts
new file mode 100644
index 0000000..c064c2c
--- /dev/null
+++ b/apps/landing/src/lib/sendLead.test.ts
@@ -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, '', '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
((_, 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');
+ });
+});
diff --git a/apps/landing/src/lib/sendLead.ts b/apps/landing/src/lib/sendLead.ts
index c237e20..7fa2ba9 100644
--- a/apps/landing/src/lib/sendLead.ts
+++ b/apps/landing/src/lib/sendLead.ts
@@ -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;
+/** Что сказать посетителю после отправки. */
+export type SubmitNotice = 'created' | 'clarified' | 'invalid' | 'rate_limited' | 'failed';
-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' };
- }
+export type SubmitResult = {
+ status: 'success' | 'error';
+ notice: SubmitNotice;
+ /** подсказки под полями при 400 */
+ fieldErrors: FieldErrors;
};
+
+export type SendLead = (body: LeadRequest) => Promise;
+
+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;
+ 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;
+ 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) => 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 fetchImpl(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ signal: controller.signal,
+ });
+ const data: unknown = await res.json().catch(() => null);
+ return resultFromResponse(res.status, data);
+ } catch {
+ return FAILED;
+ } finally {
+ clearTimeout(timer);
+ }
+ };
+}
+
+export const sendLead: SendLead = createSendLead();
diff --git a/apps/landing/src/lib/useLeadForm.ts b/apps/landing/src/lib/useLeadForm.ts
index a4fac8a..f29f692 100644
--- a/apps/landing/src/lib/useLeadForm.ts
+++ b/apps/landing/src/lib/useLeadForm.ts
@@ -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(() => {
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({});
const [status, setStatus] = useState('idle');
+ const [notice, setNotice] = useState(null);
const source = useRef(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;