fix(tradein/v2): смонтировать форму лида (#2377) в живое v2-дерево (#2395) (#2398)
All checks were successful
Deploy Trade-In / deploy (push) Successful in 43s
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m1s
All checks were successful
Deploy Trade-In / deploy (push) Successful in 43s
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m1s
This commit is contained in:
parent
480d4c2128
commit
155d5ec3a9
2 changed files with 351 additions and 0 deletions
|
|
@ -21,6 +21,7 @@ import HeroBar from "@/components/trade-in/v2/HeroBar";
|
|||
import ParamsPanel from "@/components/trade-in/v2/ParamsPanel";
|
||||
import ResultPanel from "@/components/trade-in/v2/ResultPanel";
|
||||
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 SectionOverlay from "@/components/trade-in/v2/SectionOverlay";
|
||||
import { LocationDrawer } from "@/components/trade-in/v2/LocationDrawer";
|
||||
|
|
@ -928,6 +929,14 @@ export default function TradeInV2Page() {
|
|||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
342
tradein-mvp/frontend/src/components/trade-in/v2/LeadForm.tsx
Normal file
342
tradein-mvp/frontend/src/components/trade-in/v2/LeadForm.tsx
Normal 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}>
|
||||
Введите телефон (5–32 символа: цифры, +, пробелы, скобки, дефис)
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue