From fbafb1bf68141b0f70734861bf1aff63875d3c39 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 13:12:25 +0500 Subject: [PATCH] =?UTF-8?q?fix(landing):=20mirror=20Pydantic=20schema=20?= =?UTF-8?q?=D0=B2=20PilotRequestModal=20validation=20(#1240)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend PilotRequestInput требует name min_length=2 и др., но modal проверял только !form.name.trim(). 1-char name ('Я') проходил, backend возвращал 422, apiFetch клал raw FastAPI JSON в Error.message, modal рендерил его буквально для public lead form. Patch: PILOT_LIMITS + PILOT_EMAIL_RE константы (синхр с backend), per-field onBlur+live валидация с RU-сообщениями, minLength/maxLength + aria-invalid/aria-describedby, submit disabled при ошибках, formatSubmitError → 4xx/5xx → human text. 26 vitest кейсов. Closes #1240 --- .../components/landing/PilotRequestModal.tsx | 269 +++++++++++++++--- .../__tests__/PilotRequestModal.test.tsx | 254 +++++++++++++++++ 2 files changed, 478 insertions(+), 45 deletions(-) create mode 100644 frontend/src/components/landing/__tests__/PilotRequestModal.test.tsx diff --git a/frontend/src/components/landing/PilotRequestModal.tsx b/frontend/src/components/landing/PilotRequestModal.tsx index d8710d64..aab73461 100644 --- a/frontend/src/components/landing/PilotRequestModal.tsx +++ b/frontend/src/components/landing/PilotRequestModal.tsx @@ -25,6 +25,25 @@ interface FormState { 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. @@ -34,6 +53,85 @@ function deriveTrackingId(id: string): string { return "GD-" + id.replace(/-/g, "").slice(0, 8).toUpperCase(); } +/** + * Валидирует одно поле против ограничений схемы. Возвращает текст ошибки + * (понятный пользователю) или пустую строку, если поле валидно. + * + * Экспортится для 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) { @@ -44,28 +142,48 @@ export function PilotRequestModal({ open, onClose }: Props) { company: "", message: "", }); - const [emailError, setEmailError] = useState(""); + const [errors, setErrors] = useState({}); const [trackingId, setTrackingId] = useState(null); const mutation = useSubmitPilotRequest(); - const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - function handleChange( e: React.ChangeEvent, ) { const { name, value } = e.target; - setForm((prev) => ({ ...prev, [name]: value })); - if (name === "email") { - setEmailError(value && !EMAIL_RE.test(value) ? "Некорректный email" : ""); - } + 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(); - if (!form.name.trim()) return; - if (form.email && !EMAIL_RE.test(form.email)) { - setEmailError("Некорректный email"); + const validation = validatePilotForm(form); + if (Object.keys(validation).length > 0) { + setErrors(validation); return; } @@ -76,7 +194,7 @@ export function PilotRequestModal({ open, onClose }: Props) { ...(form.email.trim() ? { email: form.email.trim() } : {}), ...(form.company.trim() ? { company: form.company.trim() } : {}), ...(form.message.trim() - ? { message: form.message.trim().slice(0, 2000) } + ? { message: form.message.trim().slice(0, PILOT_LIMITS.message.max) } : {}), }; @@ -89,12 +207,15 @@ export function PilotRequestModal({ open, onClose }: Props) { function handleClose() { setForm({ name: "", phone: "", email: "", company: "", message: "" }); - setEmailError(""); + setErrors({}); setTrackingId(null); mutation.reset(); onClose(); } + const hasBlockingErrors = Object.keys(validatePilotForm(form)).length > 0; + const submitDisabled = mutation.isPending || hasBlockingErrors; + return ( {/* Header */} @@ -191,10 +312,25 @@ export function PilotRequestModal({ open, onClose }: Props) { name="name" value={form.name} onChange={handleChange} + onBlur={handleBlur} required + minLength={PILOT_LIMITS.name.min} + maxLength={PILOT_LIMITS.name.max} placeholder="Алексей Кириллов" - style={inputStyle} + aria-invalid={errors.name ? "true" : "false"} + aria-describedby={errors.name ? "pilot-name-error" : undefined} + style={{ + ...inputStyle, + borderColor: errors.name + ? "var(--danger)" + : "var(--border-strong)", + }} /> + {errors.name ? ( + + {errors.name} + + ) : null} {/* Company */} @@ -207,9 +343,25 @@ export function PilotRequestModal({ open, onClose }: Props) { name="company" value={form.company} onChange={handleChange} + onBlur={handleBlur} + maxLength={PILOT_LIMITS.company.max} placeholder="PRINZIP" - style={inputStyle} + aria-invalid={errors.company ? "true" : "false"} + aria-describedby={ + errors.company ? "pilot-company-error" : undefined + } + style={{ + ...inputStyle, + borderColor: errors.company + ? "var(--danger)" + : "var(--border-strong)", + }} /> + {errors.company ? ( + + {errors.company} + + ) : null} {/* Phone */} @@ -223,9 +375,25 @@ export function PilotRequestModal({ open, onClose }: Props) { type="tel" value={form.phone} onChange={handleChange} + onBlur={handleBlur} + maxLength={PILOT_LIMITS.phone.max} placeholder="+7 900 000-00-00" - style={inputStyle} + aria-invalid={errors.phone ? "true" : "false"} + aria-describedby={ + errors.phone ? "pilot-phone-error" : undefined + } + style={{ + ...inputStyle, + borderColor: errors.phone + ? "var(--danger)" + : "var(--border-strong)", + }} /> + {errors.phone ? ( + + {errors.phone} + + ) : null} {/* Email */} @@ -239,24 +407,23 @@ export function PilotRequestModal({ open, onClose }: Props) { type="email" value={form.email} onChange={handleChange} + onBlur={handleBlur} + maxLength={PILOT_LIMITS.email.max} placeholder="alex@prinzip.ru" + aria-invalid={errors.email ? "true" : "false"} + aria-describedby={ + errors.email ? "pilot-email-error" : undefined + } style={{ ...inputStyle, - borderColor: emailError + borderColor: errors.email ? "var(--danger)" : "var(--border-strong)", }} /> - {emailError ? ( - - {emailError} + {errors.email ? ( + + {errors.email} ) : null} @@ -271,14 +438,22 @@ export function PilotRequestModal({ open, onClose }: Props) { name="message" value={form.message} onChange={handleChange} + onBlur={handleBlur} placeholder="Расскажите о вашем проекте или вопросе..." rows={4} - maxLength={2000} + maxLength={PILOT_LIMITS.message.max} + aria-invalid={errors.message ? "true" : "false"} + aria-describedby={ + errors.message ? "pilot-message-error" : undefined + } style={{ ...inputStyle, resize: "vertical", minHeight: 96, fontFamily: "inherit", + borderColor: errors.message + ? "var(--danger)" + : "var(--border-strong)", }} /> - {form.message.length} / 2000 + {form.message.length} / {PILOT_LIMITS.message.max} + {errors.message ? ( + + {errors.message} + + ) : null} {/* Error */} {mutation.isError ? (
- {mutation.error instanceof Error - ? mutation.error.message - : "Ошибка отправки. Попробуйте позже."} + {formatSubmitError(mutation.error)}
) : null} {/* Submit */}