fix(landing): mirror Pydantic schema в PilotRequestModal validation (#1240)

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
This commit is contained in:
Light1YT 2026-06-13 13:12:25 +05:00
parent 703d3905b8
commit fbafb1bf68
2 changed files with 478 additions and 45 deletions

View file

@ -25,6 +25,25 @@ interface FormState {
message: string;
}
type FieldName = keyof FormState;
type FieldErrors = Partial<Record<FieldName, string>>;
// ── 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<string>("");
const [errors, setErrors] = useState<FieldErrors>({});
const [trackingId, setTrackingId] = useState<string | null>(null);
const mutation = useSubmitPilotRequest();
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) {
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<HTMLInputElement | HTMLTextAreaElement>,
) {
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<HTMLFormElement>) {
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 (
<Drawer open={open} onClose={handleClose} side="bottom">
{/* 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 ? (
<span id="pilot-name-error" style={fieldErrorStyle}>
{errors.name}
</span>
) : null}
</div>
{/* 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 ? (
<span id="pilot-company-error" style={fieldErrorStyle}>
{errors.company}
</span>
) : null}
</div>
{/* 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 ? (
<span id="pilot-phone-error" style={fieldErrorStyle}>
{errors.phone}
</span>
) : null}
</div>
{/* 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 ? (
<span
style={{
fontSize: 12,
color: "var(--danger)",
marginTop: 4,
display: "block",
}}
>
{emailError}
{errors.email ? (
<span id="pilot-email-error" style={fieldErrorStyle}>
{errors.email}
</span>
) : null}
</div>
@ -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)",
}}
/>
<span
@ -289,13 +464,19 @@ export function PilotRequestModal({ open, onClose }: Props) {
display: "block",
}}
>
{form.message.length} / 2000
{form.message.length} / {PILOT_LIMITS.message.max}
</span>
{errors.message ? (
<span id="pilot-message-error" style={fieldErrorStyle}>
{errors.message}
</span>
) : null}
</div>
{/* Error */}
{mutation.isError ? (
<div
role="alert"
style={{
padding: "10px 14px",
background: "var(--danger-soft)",
@ -304,35 +485,26 @@ export function PilotRequestModal({ open, onClose }: Props) {
color: "var(--danger)",
}}
>
{mutation.error instanceof Error
? mutation.error.message
: "Ошибка отправки. Попробуйте позже."}
{formatSubmitError(mutation.error)}
</div>
) : null}
{/* Submit */}
<button
type="submit"
disabled={mutation.isPending || !form.name.trim()}
disabled={submitDisabled}
style={{
padding: "12px 24px",
background:
mutation.isPending || !form.name.trim()
? "var(--accent-soft)"
: "var(--accent)",
color:
mutation.isPending || !form.name.trim()
? "var(--accent)"
: "#fff",
background: submitDisabled
? "var(--accent-soft)"
: "var(--accent)",
color: submitDisabled ? "var(--accent)" : "#fff",
border: "none",
borderRadius: 8,
fontSize: 15,
fontWeight: 600,
cursor:
mutation.isPending || !form.name.trim()
? "not-allowed"
: "pointer",
opacity: mutation.isPending || !form.name.trim() ? 0.7 : 1,
cursor: submitDisabled ? "not-allowed" : "pointer",
opacity: submitDisabled ? 0.7 : 1,
transition: "opacity 0.15s",
}}
>
@ -369,3 +541,10 @@ const inputStyle: React.CSSProperties = {
outline: "none",
boxSizing: "border-box" as const,
};
const fieldErrorStyle: React.CSSProperties = {
fontSize: 12,
color: "var(--danger)",
marginTop: 4,
display: "block",
};

View file

@ -0,0 +1,254 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, beforeEach, afterEach } from "vitest";
import {
PILOT_EMAIL_RE,
PILOT_LIMITS,
PilotRequestModal,
formatSubmitError,
validatePilotField,
validatePilotForm,
} from "../PilotRequestModal";
// ── Schema parity with backend Pydantic PilotRequestInput ─────────────────
// `backend/app/api/v1/pilot.py` defines:
// name: min_length=2, max_length=200
// phone: max_length=50
// email: max_length=200, pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
// company: max_length=200
// message: max_length=2000
describe("PilotRequestModal — schema mirror", () => {
it("PILOT_LIMITS matches backend constraints", () => {
expect(PILOT_LIMITS).toEqual({
name: { min: 2, max: 200 },
phone: { max: 50 },
email: { max: 200 },
company: { max: 200 },
message: { max: 2000 },
});
});
it("PILOT_EMAIL_RE matches backend Pydantic pattern", () => {
// Source-of-truth string from pilot.py:31
expect(PILOT_EMAIL_RE.source).toBe("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$");
});
});
describe("validatePilotField — name", () => {
it("rejects empty name", () => {
expect(validatePilotField("name", "")).toBe("Укажите имя");
expect(validatePilotField("name", " ")).toBe("Укажите имя");
});
it("rejects single-char name (matches backend min_length=2)", () => {
// Это центральный кейс issue #1240: «Я» проходило старую проверку и упиралось в 422.
expect(validatePilotField("name", "Я")).toBe("Минимум 2 символа");
});
it("accepts 2+ char name", () => {
expect(validatePilotField("name", "Ян")).toBe("");
expect(validatePilotField("name", "Алексей Кириллов")).toBe("");
});
it("rejects >200 char name", () => {
const long = "А".repeat(201);
expect(validatePilotField("name", long)).toBe("Максимум 200 символов");
});
it("accepts exactly 200 char name", () => {
expect(validatePilotField("name", "А".repeat(200))).toBe("");
});
});
describe("validatePilotField — phone", () => {
it("accepts empty / short phone", () => {
expect(validatePilotField("phone", "")).toBe("");
expect(validatePilotField("phone", "+7 900 000-00-00")).toBe("");
});
it("rejects >50 char phone", () => {
expect(validatePilotField("phone", "+".repeat(51))).toBe(
"Максимум 50 символов",
);
});
});
describe("validatePilotField — email", () => {
it("accepts empty email (optional field)", () => {
expect(validatePilotField("email", "")).toBe("");
});
it("rejects malformed email", () => {
expect(validatePilotField("email", "no-at-sign")).toBe("Некорректный email");
expect(validatePilotField("email", "user@")).toBe("Некорректный email");
expect(validatePilotField("email", "user@host")).toBe("Некорректный email");
expect(validatePilotField("email", "@host.ru")).toBe("Некорректный email");
});
it("accepts well-formed email", () => {
expect(validatePilotField("email", "alex@prinzip.ru")).toBe("");
expect(validatePilotField("email", "a.b+tag@example.co.uk")).toBe("");
});
it("rejects >200 char email", () => {
// 196 a-chars + "@b.ru" (5) = 201 chars total → over the limit.
const tooLong = "a".repeat(196) + "@b.ru";
expect(tooLong.length).toBe(201);
expect(validatePilotField("email", tooLong)).toBe("Максимум 200 символов");
});
});
describe("validatePilotField — company / message", () => {
it("rejects >200 char company", () => {
expect(validatePilotField("company", "x".repeat(201))).toBe(
"Максимум 200 символов",
);
});
it("rejects >2000 char message", () => {
expect(validatePilotField("message", "x".repeat(2001))).toBe(
"Максимум 2000 символов",
);
});
});
describe("validatePilotForm — composite", () => {
it("returns empty object for valid form", () => {
expect(
validatePilotForm({
name: "Алексей",
phone: "+7 900 000-00-00",
email: "alex@prinzip.ru",
company: "PRINZIP",
message: "Здравствуйте",
}),
).toEqual({});
});
it("returns error map for invalid form", () => {
const errors = validatePilotForm({
name: "Я",
phone: "",
email: "bad-email",
company: "",
message: "",
});
expect(errors.name).toBe("Минимум 2 символа");
expect(errors.email).toBe("Некорректный email");
expect(errors.phone).toBeUndefined();
});
});
describe("formatSubmitError — friendly 422 mapping", () => {
it("returns generic fallback for non-Error", () => {
expect(formatSubmitError(null)).toMatch(/Не удалось/);
expect(formatSubmitError("string")).toMatch(/Не удалось/);
});
it("maps 422 to friendly text instead of raw FastAPI JSON", () => {
// Это центральный кейс issue #1240: apiFetch кидает «API error 422: {...}»
// и модалка раньше рендерила JSON verbatim. Теперь — человеческий текст.
const raw = `API error 422: {"detail":[{"type":"string_too_short","loc":["body","name"],"msg":"String should have at least 2 characters","input":"Я","ctx":{"min_length":2}}]}`;
expect(formatSubmitError(new Error(raw))).toBe(
"Проверьте корректность заполнения полей.",
);
});
it("maps 500 to friendly fallback", () => {
expect(formatSubmitError(new Error("API error 500: boom"))).toMatch(
/Сервер недоступен/,
);
});
it("uses detail field if 4xx body is JSON with string detail", () => {
const raw = `API error 400: {"detail":"Custom backend message"}`;
expect(formatSubmitError(new Error(raw))).toBe("Custom backend message");
});
it("returns generic fallback on non-API-error message", () => {
expect(formatSubmitError(new Error("network error"))).toMatch(/Не удалось/);
});
});
// ── Component-level integration ─────────────────────────────────────────
function renderModal() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<PilotRequestModal open={true} onClose={() => {}} />
</QueryClientProvider>,
);
}
describe("PilotRequestModal — component behaviour", () => {
beforeEach(() => {
vi.spyOn(globalThis, "fetch").mockImplementation(
() => new Promise(() => {}),
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("disables submit until name passes min_length", async () => {
const user = userEvent.setup();
renderModal();
const submit = screen.getByRole("button", { name: /Отправить заявку/i });
expect(submit).toBeDisabled();
const nameInput = screen.getByLabelText(/Имя/i);
await user.type(nameInput, "Я");
expect(submit).toBeDisabled();
await user.type(nameInput, "н");
expect(submit).toBeEnabled();
});
it("shows inline name error on blur with 1-char name (issue #1240 trace)", async () => {
renderModal();
const nameInput = screen.getByLabelText(/Имя/i) as HTMLInputElement;
fireEvent.change(nameInput, { target: { value: "Я" } });
fireEvent.blur(nameInput);
expect(await screen.findByText(/Минимум 2 символа/i)).toBeInTheDocument();
// Submit should be blocked — no raw JSON 422 from backend reaches the user.
expect(
screen.getByRole("button", { name: /Отправить заявку/i }),
).toBeDisabled();
});
it("shows email error live when user types invalid value", async () => {
renderModal();
const nameInput = screen.getByLabelText(/Имя/i);
fireEvent.change(nameInput, { target: { value: "Алексей" } });
const emailInput = screen.getByLabelText(/Email/i) as HTMLInputElement;
fireEvent.change(emailInput, { target: { value: "no-at" } });
expect(await screen.findByText(/Некорректный email/i)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Отправить заявку/i }),
).toBeDisabled();
});
it("sets HTML attrs mirroring schema (minLength/maxLength)", () => {
renderModal();
expect(screen.getByLabelText(/Имя/i)).toHaveAttribute("minLength", "2");
expect(screen.getByLabelText(/Имя/i)).toHaveAttribute("maxLength", "200");
expect(screen.getByLabelText(/Компания/i)).toHaveAttribute(
"maxLength",
"200",
);
expect(screen.getByLabelText(/Телефон/i)).toHaveAttribute(
"maxLength",
"50",
);
expect(screen.getByLabelText(/Email/i)).toHaveAttribute("maxLength", "200");
expect(screen.getByLabelText(/Сообщение/i)).toHaveAttribute(
"maxLength",
"2000",
);
});
});