feat(landing): состояния формы и ошибки отправки
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user