Hero + 5 KPI cards from GET /api/v1/landing/stats (B4, real DB), Pricing card 30k руб/мес, FAQ accordion, PilotRequestModal -> POST /api/v1/pilot/request (B3).
Round 2 fixes:
- B4 fail-loud: LandingStatsOut.stale flag, zeroed fallback, frontend skeleton
- B3 UUID contract: id:string, display tracking GD-{first8hex}
- PilotRequestModal: replaced manual overlay with <Drawer side="bottom"> (A3 #341) — focus trap, ESC, scroll-lock, focus restore
- Design tokens: all hex literals replaced with var(--*)
- HeadlineBar component used instead of hand-rolled dark div
Closes #82
Supersedes #366
371 lines
11 KiB
TypeScript
371 lines
11 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;
|
||
}
|
||
|
||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
/** Derives a human-readable tracking ID from the UUID string returned by backend.
|
||
* Example: "7f3a8c2e-..." → "GD-7F3A8C2E"
|
||
*/
|
||
function deriveTrackingId(id: string): string {
|
||
return "GD-" + id.replace(/-/g, "").slice(0, 8).toUpperCase();
|
||
}
|
||
|
||
// ── Component ──────────────────────────────────────────────────────────────
|
||
|
||
export function PilotRequestModal({ open, onClose }: Props) {
|
||
const [form, setForm] = useState<FormState>({
|
||
name: "",
|
||
phone: "",
|
||
email: "",
|
||
company: "",
|
||
message: "",
|
||
});
|
||
const [emailError, setEmailError] = useState<string>("");
|
||
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" : "");
|
||
}
|
||
}
|
||
|
||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||
e.preventDefault();
|
||
if (!form.name.trim()) return;
|
||
if (form.email && !EMAIL_RE.test(form.email)) {
|
||
setEmailError("Некорректный email");
|
||
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, 2000) }
|
||
: {}),
|
||
};
|
||
|
||
mutation.mutate(payload, {
|
||
onSuccess: (data) => {
|
||
setTrackingId(deriveTrackingId(data.id));
|
||
},
|
||
});
|
||
}
|
||
|
||
function handleClose() {
|
||
setForm({ name: "", phone: "", email: "", company: "", message: "" });
|
||
setEmailError("");
|
||
setTrackingId(null);
|
||
mutation.reset();
|
||
onClose();
|
||
}
|
||
|
||
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}
|
||
required
|
||
placeholder="Алексей Кириллов"
|
||
style={inputStyle}
|
||
/>
|
||
</div>
|
||
|
||
{/* Company */}
|
||
<div>
|
||
<label htmlFor="pilot-company" style={labelStyle}>
|
||
Компания
|
||
</label>
|
||
<input
|
||
id="pilot-company"
|
||
name="company"
|
||
value={form.company}
|
||
onChange={handleChange}
|
||
placeholder="PRINZIP"
|
||
style={inputStyle}
|
||
/>
|
||
</div>
|
||
|
||
{/* Phone */}
|
||
<div>
|
||
<label htmlFor="pilot-phone" style={labelStyle}>
|
||
Телефон
|
||
</label>
|
||
<input
|
||
id="pilot-phone"
|
||
name="phone"
|
||
type="tel"
|
||
value={form.phone}
|
||
onChange={handleChange}
|
||
placeholder="+7 900 000-00-00"
|
||
style={inputStyle}
|
||
/>
|
||
</div>
|
||
|
||
{/* Email */}
|
||
<div>
|
||
<label htmlFor="pilot-email" style={labelStyle}>
|
||
Email
|
||
</label>
|
||
<input
|
||
id="pilot-email"
|
||
name="email"
|
||
type="email"
|
||
value={form.email}
|
||
onChange={handleChange}
|
||
placeholder="alex@prinzip.ru"
|
||
style={{
|
||
...inputStyle,
|
||
borderColor: emailError
|
||
? "var(--danger)"
|
||
: "var(--border-strong)",
|
||
}}
|
||
/>
|
||
{emailError ? (
|
||
<span
|
||
style={{
|
||
fontSize: 12,
|
||
color: "var(--danger)",
|
||
marginTop: 4,
|
||
display: "block",
|
||
}}
|
||
>
|
||
{emailError}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
{/* Message */}
|
||
<div>
|
||
<label htmlFor="pilot-message" style={labelStyle}>
|
||
Сообщение
|
||
</label>
|
||
<textarea
|
||
id="pilot-message"
|
||
name="message"
|
||
value={form.message}
|
||
onChange={handleChange}
|
||
placeholder="Расскажите о вашем проекте или вопросе..."
|
||
rows={4}
|
||
maxLength={2000}
|
||
style={{
|
||
...inputStyle,
|
||
resize: "vertical",
|
||
minHeight: 96,
|
||
fontFamily: "inherit",
|
||
}}
|
||
/>
|
||
<span
|
||
style={{
|
||
fontSize: 12,
|
||
color: "var(--fg-tertiary)",
|
||
marginTop: 2,
|
||
display: "block",
|
||
}}
|
||
>
|
||
{form.message.length} / 2000
|
||
</span>
|
||
</div>
|
||
|
||
{/* Error */}
|
||
{mutation.isError ? (
|
||
<div
|
||
style={{
|
||
padding: "10px 14px",
|
||
background: "var(--danger-soft)",
|
||
borderRadius: 8,
|
||
fontSize: 13,
|
||
color: "var(--danger)",
|
||
}}
|
||
>
|
||
{mutation.error instanceof Error
|
||
? mutation.error.message
|
||
: "Ошибка отправки. Попробуйте позже."}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Submit */}
|
||
<button
|
||
type="submit"
|
||
disabled={mutation.isPending || !form.name.trim()}
|
||
style={{
|
||
padding: "12px 24px",
|
||
background:
|
||
mutation.isPending || !form.name.trim()
|
||
? "var(--accent-soft)"
|
||
: "var(--accent)",
|
||
color:
|
||
mutation.isPending || !form.name.trim()
|
||
? "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,
|
||
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,
|
||
};
|