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
+76 -23
View File
@@ -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) => {
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<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 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();