gendesign/frontend/src/components/landing/PilotRequestModal.tsx
Light1YT 86e9ea2937 fix(week-review): автофиксы код-ревью — 169 issue (label «week ревью 1»)
Многоагентный аудит + имплементация: один воркер на файл, точечные правки.
Верификация: py_compile (47/47 .py) + tsc --noEmit (0 ошибок). Unit-тесты
не прогонялись (окружение не поднято: rollup native dep / нет pytest-venv).

Полностью исправлено (169): #1336, #1337, #1339, #1340, #1341, #1342, #1343, #1345, #1346, #1348, #1349, #1350, #1351, #1354, #1356, #1358, #1359, #1360, #1362, #1364, #1365, #1366, #1367, #1368, #1369, #1370, #1371, #1372, #1373, #1374, #1375, #1376, #1377, #1378, #1379, #1380, #1381, #1382, #1384, #1385, #1386, #1387, #1388, #1389, #1390, #1391, #1392, #1394, #1395, #1396, #1397, #1399, #1400, #1401, #1402, #1403, #1404, #1408, #1409, #1410, #1411, #1412, #1413, #1414, #1415, #1416, #1417, #1418, #1420, #1423, #1425, #1426, #1427, #1428, #1429, #1430, #1431, #1432, #1433, #1434, #1435, #1437, #1438, #1439, #1440, #1441, #1442, #1443, #1444, #1445, #1446, #1447, #1448, #1449, #1450, #1451, #1452, #1453, #1454, #1455, #1456, #1457, #1458, #1459, #1460, #1461, #1462, #1463, #1464, #1465, #1466, #1467, #1468, #1469, #1471, #1472, #1473, #1474, #1476, #1478, #1479, #1481, #1482, #1483, #1484, #1485, #1487, #1488, #1489, #1490, #1491, #1492, #1493, #1494, #1495, #1496, #1497, #1499, #1500, #1501, #1502, #1504, #1505, #1506, #1507, #1510, #1514, #1515, #1516, #1517, #1518, #1519, #1521, #1522, #1523, #1524, #1525, #1526, #1527, #1528, #1529, #1531, #1532, #1533, #1534, #1535, #1536, #1537, #1538

Частично (9, in-file часть, остаток cross-file): #1361, #1419, #1422, #1424, #1470, #1475, #1477, #1480, #1498
Требуют cross-file (3, не тронуты): #1338, #1363, #1421
Пропущено (1): #1539

Не входило в партию: 22 needs-Leha issue (нужны решения владельца).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:21:11 +05:00

557 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<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.
*
* Важно: показанный лиду номер ДОЛЖЕН находиться поддержкой в БД. Бэкенд
* (`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<FormState>({
name: "",
phone: "",
email: "",
company: "",
message: "",
});
const [errors, setErrors] = useState<FieldErrors>({});
const [trackingId, setTrackingId] = useState<string | null>(null);
const mutation = useSubmitPilotRequest();
function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) {
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<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();
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 (
<Drawer open={open} onClose={handleClose} side="bottom">
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "20px 24px 0",
marginBottom: 20,
}}
>
<h2
style={{
margin: 0,
fontSize: 18,
fontWeight: 600,
color: "var(--fg-primary)",
}}
>
Запросить пилотный доступ
</h2>
</div>
<div style={{ padding: "0 24px 32px" }}>
{/* Success state */}
{trackingId !== null ? (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: 12,
padding: "24px 0",
}}
>
<CheckCircle size={40} color="var(--success)" strokeWidth={1.5} />
<p
style={{
margin: 0,
fontSize: 16,
fontWeight: 600,
color: "var(--fg-primary)",
}}
>
Заявка принята
</p>
<p
style={{
margin: 0,
fontSize: 14,
color: "var(--fg-secondary)",
lineHeight: 1.5,
}}
>
Мы свяжемся с вами в течение 12 рабочих дней. Номер заявки:{" "}
<span
style={{
fontVariantNumeric: "tabular-nums",
color: "var(--fg-primary)",
}}
>
{trackingId}
</span>
</p>
<button
onClick={handleClose}
style={{
marginTop: 8,
padding: "8px 20px",
background: "var(--accent)",
color: "#fff",
border: "none",
borderRadius: 8,
fontSize: 14,
fontWeight: 500,
cursor: "pointer",
}}
>
Закрыть
</button>
</div>
) : (
/* Form */
<form onSubmit={handleSubmit} noValidate>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* Name */}
<div>
<label htmlFor="pilot-name" style={labelStyle}>
Имя *
</label>
<input
id="pilot-name"
name="name"
value={form.name}
onChange={handleChange}
onBlur={handleBlur}
required
minLength={PILOT_LIMITS.name.min}
maxLength={PILOT_LIMITS.name.max}
placeholder="Алексей Кириллов"
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 */}
<div>
<label htmlFor="pilot-company" style={labelStyle}>
Компания
</label>
<input
id="pilot-company"
name="company"
value={form.company}
onChange={handleChange}
onBlur={handleBlur}
maxLength={PILOT_LIMITS.company.max}
placeholder="PRINZIP"
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 */}
<div>
<label htmlFor="pilot-phone" style={labelStyle}>
Телефон
</label>
<input
id="pilot-phone"
name="phone"
type="tel"
value={form.phone}
onChange={handleChange}
onBlur={handleBlur}
maxLength={PILOT_LIMITS.phone.max}
placeholder="+7 900 000-00-00"
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 */}
<div>
<label htmlFor="pilot-email" style={labelStyle}>
Email
</label>
<input
id="pilot-email"
name="email"
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: errors.email
? "var(--danger)"
: "var(--border-strong)",
}}
/>
{errors.email ? (
<span id="pilot-email-error" style={fieldErrorStyle}>
{errors.email}
</span>
) : null}
</div>
{/* Message */}
<div>
<label htmlFor="pilot-message" style={labelStyle}>
Сообщение
</label>
<textarea
id="pilot-message"
name="message"
value={form.message}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Расскажите о вашем проекте или вопросе..."
rows={4}
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
style={{
fontSize: 12,
color: "var(--fg-tertiary)",
marginTop: 2,
display: "block",
}}
>
{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)",
borderRadius: 8,
fontSize: 13,
color: "var(--danger)",
}}
>
{formatSubmitError(mutation.error)}
</div>
) : null}
{/* Submit */}
<button
type="submit"
disabled={submitDisabled}
style={{
padding: "12px 24px",
background: submitDisabled
? "var(--accent-soft)"
: "var(--accent)",
color: submitDisabled ? "var(--accent)" : "#fff",
border: "none",
borderRadius: 8,
fontSize: 15,
fontWeight: 600,
cursor: submitDisabled ? "not-allowed" : "pointer",
opacity: submitDisabled ? 0.7 : 1,
transition: "opacity 0.15s",
}}
>
{mutation.isPending ? "Отправка..." : "Отправить заявку"}
</button>
</div>
</form>
)}
</div>
</Drawer>
);
}
// ── Shared styles ──────────────────────────────────────────────────────────
const labelStyle: React.CSSProperties = {
display: "block",
fontSize: 12,
fontWeight: 500,
textTransform: "uppercase" as const,
letterSpacing: "0.04em",
color: "var(--fg-secondary)",
marginBottom: 6,
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "9px 12px",
border: "1px solid var(--border-strong)",
borderRadius: 8,
fontSize: 14,
color: "var(--fg-primary)",
background: "var(--bg-card)",
outline: "none",
boxSizing: "border-box" as const,
};
const fieldErrorStyle: React.CSSProperties = {
fontSize: 12,
color: "var(--danger)",
marginTop: 4,
display: "block",
};