fix(tradein/v2): смонтировать форму лида (#2377) в живое v2-дерево (#2395) #2398

Merged
lekss361 merged 2 commits from fix/tradein-2395-lead-form-v2 into main 2026-07-04 08:46:11 +00:00
2 changed files with 351 additions and 0 deletions

View file

@ -21,6 +21,7 @@ import HeroBar from "@/components/trade-in/v2/HeroBar";
import ParamsPanel from "@/components/trade-in/v2/ParamsPanel"; import ParamsPanel from "@/components/trade-in/v2/ParamsPanel";
import ResultPanel from "@/components/trade-in/v2/ResultPanel"; import ResultPanel from "@/components/trade-in/v2/ResultPanel";
import { ObjectSummary } from "@/components/trade-in/v2/ObjectSummary"; import { ObjectSummary } from "@/components/trade-in/v2/ObjectSummary";
import { LeadForm } from "@/components/trade-in/v2/LeadForm";
import { Footer } from "@/components/trade-in/v2/Footer"; import { Footer } from "@/components/trade-in/v2/Footer";
import SectionOverlay from "@/components/trade-in/v2/SectionOverlay"; import SectionOverlay from "@/components/trade-in/v2/SectionOverlay";
import { LocationDrawer } from "@/components/trade-in/v2/LocationDrawer"; import { LocationDrawer } from "@/components/trade-in/v2/LocationDrawer";
@ -928,6 +929,14 @@ export default function TradeInV2Page() {
/> />
</div> </div>
</div> </div>
{/* Lead form (issue #2395 v2-native mount of #2377's lead capture,
previously stranded in the dead HeroTransparency/HeroSummary tree).
Lives outside the scaled 1536×1024 artboard transform (in normal page
flow) so it stays legible at any viewport/scale instead of shrinking
with the HUD; only shown once a real, sufficient estimate is on
screen never on the empty/insufficient-data states. */}
{hasEstimate && <LeadForm estimateId={currentEstimateId} />}
</div> </div>
); );
} }

View file

@ -0,0 +1,342 @@
"use client";
// LeadForm — «Оставить заявку на трейд-ин» v2-native card (issue #2395: fixup
// of #2377 — the working lead form only existed in the dead HeroTransparency/
// HeroSummary legacy tree, unreachable from the live `/trade-in/v2` route,
// which redirects everything else. This is the v2-native mount).
//
// Reused AS-IS (per #2395 DoD): `useCreateLeadMutation` (trade-in-api.ts) +
// `TradeInLeadInput`/`TradeInLeadResponse` types — backend is untouched and
// already live (`POST /api/v1/trade-in/lead`). The state machine / validation /
// submit-handler shape is ported from HeroTransparency.tsx (idle → submitting →
// success/error, phone regex mirroring the backend's `_PHONE_PATTERN`, explicit
// ФЗ-152 consent) — but the markup/styling below is a fresh v2 HUD card built on
// `tokens.ts`, not the legacy `.hero-lead*` CSS classes (those stay in
// trade-in.css, unused by this component).
//
// Critical fixup carried over from the #2377 code review: the submit <button>
// must NEVER receive the `disabled` attribute from validity state — only from
// `leadMutation.isPending`. A `disabled` button does not dispatch a submit
// event, so an invalid attempt would silently no-op instead of running the
// handler and showing a hint. The form has `noValidate` and every submit
// (valid or not) goes through `handleSubmit`, which surfaces an inline
// explanation for whichever field blocked it.
import { useState } from "react";
import type { CSSProperties, FormEvent } from "react";
import { tokens } from "./tokens";
import { useCreateLeadMutation } from "@/lib/trade-in-api";
const {
accent,
accentDeep,
onAccent,
ink,
body2,
muted,
line,
line2,
danger,
success,
bracket,
surface,
font,
} = tokens;
// Mirrors the backend's `_PHONE_PATTERN` (tradein-mvp/backend/app/api/v1/
// lead.py): optional "+", digits/spaces/parens/dashes/dots, 5-32 chars.
// Client-side validation gives instant feedback ahead of the network 422 (full
// E.164 normalization is out of MVP scope). Duplicated rather than imported
// from HeroTransparency.tsx — that component is legacy/unreachable and this
// file is intentionally zero-coupled to it.
const PHONE_PATTERN = /^[+]?[\d\s().-]{5,32}$/;
function isValidPhone(v: string): boolean {
return PHONE_PATTERN.test(v.trim());
}
const styles = `
.lf-input:focus-visible{outline:none;border-color:${accentDeep};box-shadow:0 0 0 2px ${accentDeep}}
.lf-submit-btn{background:linear-gradient(90deg,${accentDeep},${accent});transition:all .18s}
.lf-submit-btn:hover:not(:disabled){background:${accentDeep};box-shadow:0 6px 18px rgba(46,139,255,.35)}
.lf-submit-btn:active:not(:disabled){transform:translateY(1px)}
.lf-submit-btn:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(46,139,255,.35)}
`;
const cardStyle: CSSProperties = {
position: "relative",
width: "100%",
maxWidth: 640,
margin: "16px auto 0",
boxSizing: "border-box",
background: surface.w50,
backdropFilter: "blur(6px)",
border: `1px solid ${line2}`,
borderRadius: 8,
padding: "18px 22px 20px",
fontFamily: font.sans,
color: ink,
};
const bracketBase: CSSProperties = {
position: "absolute",
width: 13,
height: 13,
pointerEvents: "none",
};
const inputStyle: CSSProperties = {
height: 36,
flex: 1,
minWidth: 0,
boxSizing: "border-box",
background: surface.w62,
border: `1px solid ${line}`,
borderRadius: 6,
padding: "0 13px",
fontFamily: font.sans,
fontSize: 13,
color: ink,
outline: "none",
};
const errorTextStyle: CSSProperties = {
marginTop: 8,
fontSize: 10.5,
letterSpacing: 0.3,
lineHeight: 1.4,
color: danger,
};
interface LeadFormProps {
/** estimate_id для привязки заявки (POST /lead, source="result"). Компонент
* монтируется только при hasEstimate=true (см. app/v2/page.tsx), поэтому на
* практике почти всегда non-null но проп остаётся nullable честно, т.к.
* TradeInLeadInput.estimate_id опционален и на бэкенде. */
estimateId: string | null;
}
export function LeadForm({ estimateId }: LeadFormProps) {
const [phone, setPhone] = useState("");
const [consent, setConsent] = useState(false);
const [touched, setTouched] = useState(false);
const leadMutation = useCreateLeadMutation();
const phoneInvalid = touched && !isValidPhone(phone);
const consentInvalid = touched && !phoneInvalid && !consent;
const canSubmit = isValidPhone(phone) && consent && !leadMutation.isPending;
const apiError = leadMutation.isError
? leadMutation.error?.message || "Не удалось отправить заявку, попробуйте ещё раз"
: null;
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setTouched(true);
if (!isValidPhone(phone) || !consent || leadMutation.isPending) return;
leadMutation.mutate({
phone: phone.trim(),
consent: true,
estimate_id: estimateId,
source: "result",
});
}
if (leadMutation.isSuccess) {
return (
<div style={cardStyle}>
<div
style={{
...bracketBase,
left: -1,
top: -1,
borderLeft: `1.5px solid ${bracket}`,
borderTop: `1.5px solid ${bracket}`,
}}
/>
<div
style={{
...bracketBase,
right: -1,
bottom: -1,
borderRight: `1.5px solid ${bracket}`,
borderBottom: `1.5px solid ${bracket}`,
}}
/>
<p
role="status"
style={{
display: "flex",
alignItems: "center",
gap: 8,
margin: 0,
fontSize: 12.5,
fontWeight: 600,
color: success,
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
Заявка принята, мы свяжемся с вами
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} noValidate style={cardStyle}>
<style>{styles}</style>
<div
style={{
...bracketBase,
left: -1,
top: -1,
borderLeft: `1.5px solid ${bracket}`,
borderTop: `1.5px solid ${bracket}`,
}}
/>
<div
style={{
...bracketBase,
right: -1,
bottom: -1,
borderRight: `1.5px solid ${bracket}`,
borderBottom: `1.5px solid ${bracket}`,
}}
/>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 14,
}}
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke={accent}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 4h16v12H5.17L4 17.17V4z" />
<line x1="8" y1="9" x2="16" y2="9" />
<line x1="8" y1="12.5" x2="13" y2="12.5" />
</svg>
<span style={{ fontSize: 13, fontWeight: 600, letterSpacing: 2 }}>
ОСТАВИТЬ ЗАЯВКУ НА ТРЕЙД-ИН
</span>
</div>
<div style={{ display: "flex", gap: 10, alignItems: "flex-start" }}>
<input
className="lf-input"
type="tel"
inputMode="tel"
autoComplete="tel"
placeholder="+7 900 000-00-00"
aria-label="Телефон для связи"
aria-invalid={phoneInvalid || undefined}
value={phone}
onChange={(e) => setPhone(e.target.value)}
disabled={leadMutation.isPending}
style={
phoneInvalid
? { ...inputStyle, border: `1px solid ${danger}` }
: inputStyle
}
/>
<button
type="submit"
className="lf-submit-btn"
disabled={leadMutation.isPending}
aria-disabled={canSubmit ? undefined : true}
style={{
height: 36,
flex: "0 0 auto",
padding: "0 20px",
border: "none",
borderRadius: 6,
fontFamily: "inherit",
fontSize: 12,
fontWeight: 600,
letterSpacing: 1,
color: onAccent,
cursor: leadMutation.isPending ? "wait" : "pointer",
opacity: canSubmit ? 1 : 0.55,
whiteSpace: "nowrap",
}}
>
{leadMutation.isPending ? "ОТПРАВЛЯЕМ…" : "ОСТАВИТЬ ЗАЯВКУ"}
</button>
</div>
<label
style={{
display: "flex",
alignItems: "flex-start",
gap: 8,
marginTop: 12,
fontSize: 11,
lineHeight: 1.45,
color: body2,
cursor: leadMutation.isPending ? "default" : "pointer",
}}
>
<input
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
disabled={leadMutation.isPending}
aria-invalid={consentInvalid || undefined}
style={{ marginTop: 2, cursor: "inherit", flex: "0 0 auto" }}
/>
<span>
Согласен(-на) на обработку персональных данных в соответствии с
Федеральным законом «О персональных данных» 152-ФЗ
</span>
</label>
{phoneInvalid && (
<p role="alert" style={errorTextStyle}>
Введите телефон (532 символа: цифры, +, пробелы, скобки, дефис)
</p>
)}
{consentInvalid && (
<p role="alert" style={errorTextStyle}>
Необходимо согласие на обработку персональных данных
</p>
)}
{apiError && (
<p role="alert" style={errorTextStyle}>
{apiError}
</p>
)}
<p
style={{
marginTop: 10,
marginBottom: 0,
fontSize: 9.5,
letterSpacing: 0.3,
color: muted,
}}
>
Менеджер свяжется с вами для уточнения деталей трейд-ина
</p>
</form>
);
}