gendesign/frontend/src/components/landing/PilotRequestModal.tsx
lekss361 f242d9bc44
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m27s
Deploy / build-worker (push) Successful in 2m35s
Deploy / build-frontend (push) Successful in 4m41s
Deploy / deploy (push) Successful in 45s
feat(sf-fe-a12): Landing redesign + PilotCTA с real B3/B4 data (v2 rebased) (#367)
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
2026-05-18 05:16:57 +00:00

371 lines
11 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;
}
// ── 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,
}}
>
Мы свяжемся с вами в течение 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}
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,
};