"use client"; import { useState } from "react"; import { CheckCircle } from "lucide-react"; import { Drawer } from "@/components/ui/Drawer"; import { useSubmitPilotRequest, type PilotRequestPayload, } from "@/lib/api/landing"; // ── Types ────────────────────────────────────────────────────────────────── interface Props { open: boolean; onClose: () => void; } interface FormState { name: string; phone: string; email: string; company: string; message: string; } type FieldName = keyof FormState; type FieldErrors = Partial>; // ── Schema mirror ────────────────────────────────────────────────────────── // Зеркалирует backend Pydantic-схему `PilotRequestInput` // (`backend/app/api/v1/pilot.py:25-34`). При изменении на бэкенде — // синхронизировать здесь, иначе лид получит 422 и сырой JSON в модалке. export const PILOT_LIMITS = { name: { min: 2, max: 200 }, phone: { max: 50 }, email: { max: 200 }, company: { max: 200 }, message: { max: 2000 }, } as const; // Тот же паттерн, что и в Pydantic Field(pattern=...) на бэкенде // (`backend/app/api/v1/pilot.py:31`). export const PILOT_EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; // ── Helpers ──────────────────────────────────────────────────────────────── /** Derives a human-readable tracking ID from the UUID string returned by backend. * * Важно: показанный лиду номер ДОЛЖЕН находиться поддержкой в БД. Бэкенд * (`backend/app/api/v1/pilot.py`) хранит и логирует полный uuid `id`, поэтому * мы берём его реальный 8-символьный префикс БЕЗ изменения регистра — он остаётся * буквальным префиксом `pilot_requests.id`. Поддержка находит заявку прямым * запросом: `SELECT * FROM pilot_requests WHERE id::text LIKE 'XXXXXXXX%'`. * Префикс "GD-" — только визуальный, в запрос НЕ входит. * Example: "7f3a8c2e-..." → "GD-7f3a8c2e" */ function deriveTrackingId(id: string): string { return "GD-" + id.replace(/-/g, "").slice(0, 8); } /** * Валидирует одно поле против ограничений схемы. Возвращает текст ошибки * (понятный пользователю) или пустую строку, если поле валидно. * * Экспортится для unit-тестов и потенциального переиспользования. */ export function validatePilotField(name: FieldName, value: string): string { const trimmed = value.trim(); switch (name) { case "name": if (!trimmed) return "Укажите имя"; if (trimmed.length < PILOT_LIMITS.name.min) return `Минимум ${PILOT_LIMITS.name.min} символа`; if (trimmed.length > PILOT_LIMITS.name.max) return `Максимум ${PILOT_LIMITS.name.max} символов`; return ""; case "phone": if (trimmed.length > PILOT_LIMITS.phone.max) return `Максимум ${PILOT_LIMITS.phone.max} символов`; return ""; case "email": if (!trimmed) return ""; if (trimmed.length > PILOT_LIMITS.email.max) return `Максимум ${PILOT_LIMITS.email.max} символов`; if (!PILOT_EMAIL_RE.test(trimmed)) return "Некорректный email"; return ""; case "company": if (trimmed.length > PILOT_LIMITS.company.max) return `Максимум ${PILOT_LIMITS.company.max} символов`; return ""; case "message": if (trimmed.length > PILOT_LIMITS.message.max) return `Максимум ${PILOT_LIMITS.message.max} символов`; return ""; default: return ""; } } /** Валидирует все поля разом. Пустой объект — форма валидна. */ export function validatePilotForm(form: FormState): FieldErrors { const errors: FieldErrors = {}; (Object.keys(form) as FieldName[]).forEach((key) => { const err = validatePilotField(key, form[key]); if (err) errors[key] = err; }); return errors; } /** * Дружелюбное сообщение об ошибке отправки. * apiFetch (`lib/api.ts:39`) кладёт сырой response.text() в Error.message * как «API error 422: {"detail":[...]}». На публичной лид-форме сырой JSON * показывать нельзя — поэтому мапим коды в человеческий текст. На 422 это * fallback при рассинхроне фронт/бэк валидации; клиент-сайд должен ловить * это до отправки. */ export function formatSubmitError(err: unknown): string { const fallback = "Не удалось отправить заявку. Попробуйте позже."; if (!(err instanceof Error)) return fallback; const match = err.message.match(/^API error (\d{3}):\s*(.*)$/s); if (!match) return fallback; const status = Number(match[1]); const body = match[2]; if (status === 422) { return "Проверьте корректность заполнения полей."; } if (status >= 500) { return "Сервер недоступен. Попробуйте позже."; } try { const parsed = JSON.parse(body) as { detail?: unknown }; if (typeof parsed.detail === "string") return parsed.detail; } catch { // not JSON — fall through to fallback } return fallback; } // ── Component ────────────────────────────────────────────────────────────── export function PilotRequestModal({ open, onClose }: Props) { const [form, setForm] = useState({ name: "", phone: "", email: "", company: "", message: "", }); const [errors, setErrors] = useState({}); const [trackingId, setTrackingId] = useState(null); const mutation = useSubmitPilotRequest(); function handleChange( e: React.ChangeEvent, ) { const { name, value } = e.target; const fieldName = name as FieldName; setForm((prev) => ({ ...prev, [fieldName]: value })); // Live re-validate только для полей где ошибка уже показана (клиент сразу // видит, что фикс «помог»), плюс email — исторически на онлайн-проверке. setErrors((prev) => { if (fieldName !== "email" && !prev[fieldName]) return prev; const err = validatePilotField(fieldName, value); const next: FieldErrors = { ...prev }; if (err) next[fieldName] = err; else delete next[fieldName]; return next; }); } function handleBlur( e: React.FocusEvent, ) { const { name, value } = e.target; const fieldName = name as FieldName; const err = validatePilotField(fieldName, value); setErrors((prev) => { const next: FieldErrors = { ...prev }; if (err) next[fieldName] = err; else delete next[fieldName]; return next; }); } function handleSubmit(e: React.FormEvent) { e.preventDefault(); const validation = validatePilotForm(form); if (Object.keys(validation).length > 0) { setErrors(validation); return; } const payload: PilotRequestPayload = { name: form.name.trim(), source: "landing", ...(form.phone.trim() ? { phone: form.phone.trim() } : {}), ...(form.email.trim() ? { email: form.email.trim() } : {}), ...(form.company.trim() ? { company: form.company.trim() } : {}), ...(form.message.trim() ? { message: form.message.trim().slice(0, PILOT_LIMITS.message.max) } : {}), }; mutation.mutate(payload, { onSuccess: (data) => { setTrackingId(deriveTrackingId(data.id)); }, }); } function handleClose() { setForm({ name: "", phone: "", email: "", company: "", message: "" }); setErrors({}); setTrackingId(null); mutation.reset(); onClose(); } const hasBlockingErrors = Object.keys(validatePilotForm(form)).length > 0; const submitDisabled = mutation.isPending || hasBlockingErrors; return ( {/* Header */}

Запросить пилотный доступ

{/* Success state */} {trackingId !== null ? (

Заявка принята

Мы свяжемся с вами в течение 1–2 рабочих дней. Номер заявки:{" "} {trackingId}

) : ( /* Form */
{/* Name */}
{errors.name ? ( {errors.name} ) : null}
{/* Company */}
{errors.company ? ( {errors.company} ) : null}
{/* Phone */}
{errors.phone ? ( {errors.phone} ) : null}
{/* Email */}
{errors.email ? ( {errors.email} ) : null}
{/* Message */}