Многоагентный аудит + имплементация: один воркер на файл, точечные правки. Верификация: 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>
557 lines
20 KiB
TypeScript
557 lines
20 KiB
TypeScript
"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,
|
||
}}
|
||
>
|
||
Мы свяжемся с вами в течение 1–2 рабочих дней. Номер заявки:{" "}
|
||
<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",
|
||
};
|