From 5adf94357ecaea9ae7d684ede5d822bc0217e46b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 4 Jul 2026 10:38:46 +0300 Subject: [PATCH] feat(tradein/v2): replace mailto lead CTA with real form (#2377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real POST /api/v1/trade-in/lead form (phone + FZ-152 consent) in HeroTransparency.tsx, replacing the mailto stub. Removed the CONTACT_EMAIL/leadEnabled env-gate — form always renders. Review fixup: the submit button was disabled whenever phone/consent were invalid, which suppresses the native submit event entirely (both click and Enter-key implicit submission) — the client-side validation hint text was unreachable dead code, and users saw only a dimmed button with no explanation. Submit is now only disabled while the mutation is pending; invalid attempts fall through to the existing handleLeadSubmit guard clause, which surfaces the phone hint via leadTouched. aria-disabled communicates the visual state without blocking the event. Also guarded the error-message render against non-string mutation errors (FastAPI 422 responses put a list in `detail`, which would otherwise stringify to "[object Object]"). --- .../components/trade-in/HeroTransparency.tsx | 139 +++++++++++++----- .../src/components/trade-in/trade-in.css | 64 ++++++++ tradein-mvp/frontend/src/lib/trade-in-api.ts | 19 +++ tradein-mvp/frontend/src/types/trade-in.ts | 19 +++ 4 files changed, 206 insertions(+), 35 deletions(-) diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx index 9be42a57..fc2d8906 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx @@ -10,9 +10,10 @@ * 2. Свежесть данных — строка «Данные актуальны на …» из last_scraped_at либо * now − data_freshness_minutes. Подчёркивает «это не устаревший PDF». * 3. «Скачать PDF-отчёт» — ссылка на brand-aware endpoint (тот же, что в OfferCard). - * 4. CTA «Оставить заявку на трейд-ин» — primary. Lead-backend ОТСУТСТВУЕТ - * (проверено: в trade_in.py нет /lead-эндпоинта) → graceful mailto-stub - * на configurable контакт. TODO(lead-flow) — заменить на реальный POST. + * 4. Форма заявки «Оставить заявку на трейд-ин» — телефон + явное согласие + * ФЗ-152 → POST /api/v1/trade-in/lead (issue #2377, backend #2376/PR #2390). + * Заменяет прежний mailto-стаб (граничный кейс без backend'а, PR #689) — + * теперь доступна всегда, без env-гейтинга по контакт-email. * * Тема — через brand-CSS-vars (--accent…), responsive, всё graceful при null-полях. */ @@ -20,12 +21,13 @@ import { useState, useEffect } from "react"; import type { AggregatedEstimate, AnalogTier, ConfidenceLevel } from "@/types/trade-in"; import { API_BASE_URL } from "@/lib/api"; +import { useCreateLeadMutation } from "@/lib/trade-in-api"; interface Props { estimate: AggregatedEstimate; - /** Активный brand slug (useActiveBrandSlug) — для brand-aware PDF + темы письма. */ + /** Активный brand slug (useActiveBrandSlug) — для brand-aware PDF endpoint. */ brandSlug: string | null; - /** Имя бренда (Brand.name) для темы заявки. null → generic. */ + /** Имя бренда (Brand.name) — подпись в заголовке формы заявки. null → generic. */ brandName: string | null; } @@ -56,11 +58,15 @@ const PRECISION_LABELS: Record = { }; /** - * Лид-инбокс. Задаётся ТОЛЬКО через env NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL. - * БЕЗ fallback: если не задан → CTA «оставить заявку» скрывается (leadEnabled), - * чтобы заявка с PII не ушла на чужой/тестовый адрес (review PR #689). + * Маска телефона для формы заявки — зеркалит _PHONE_PATTERN бэкенда + * (tradein-mvp/backend/app/api/v1/lead.py): опциональный "+", цифры/пробелы/ + * скобки/дефисы/точки, 5-32 символа. Клиентская проверка даёт мгновенный + * feedback до сетевого 422 (полная E.164-нормализация — вне scope MVP). */ -const CONTACT_EMAIL = (process.env.NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL ?? "").trim(); +const PHONE_PATTERN = /^[+]?[\d\s().-]{5,32}$/; +function isValidPhone(v: string): boolean { + return PHONE_PATTERN.test(v.trim()); +} /** * LEGACY fallback: эвристика уровня сопоставимых объектов из confidence_explanation. @@ -159,43 +165,106 @@ export function HeroTransparency({ estimate, brandSlug, brandName }: Props) { } }, [estimate.data_freshness_minutes, scrapedFresh]); const fresh = scrapedFresh ?? fallbackFresh; - // CTA показываем только при сконфигурированном лид-инбоксе (см. CONTACT_EMAIL) — review #689. - const leadEnabled = CONTACT_EMAIL.length > 0; // PDF endpoint (api/v1/trade_in.py) уже honor'ит ?brand= для white-label — // тот же паттерн, что в OfferCard, поэтому консистентно. const pdfQuery = brandSlug ? `?brand=${encodeURIComponent(brandSlug)}` : ""; const pdfHref = `${API_BASE_URL}/api/v1/trade-in/estimate/${estimate.estimate_id}/pdf${pdfQuery}`; - // Lead-стаб: mailto с предзаполненной темой/телом. Реального POST-эндпоинта нет. - const leadSubject = `Заявка на трейд-ин${brandName ? ` · ${brandName}` : ""}`; - const leadBody = [ - "Здравствуйте! Хочу обсудить трейд-ин по моей квартире.", - estimate.target_address ? `Адрес: ${estimate.target_address}` : null, - `Номер оценки: ${estimate.estimate_id}`, - ] - .filter(Boolean) - .join("\n"); - const leadHref = `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent( - leadSubject, - )}&body=${encodeURIComponent(leadBody)}`; + // ── Форма заявки (issue #2377) — телефон + согласие ФЗ-152 → POST /lead. ── + // Controlled inputs (useState), без useEffect для HTTP — TanStack Query mutation. + const [phone, setPhone] = useState(""); + const [consent, setConsent] = useState(false); + const [leadTouched, setLeadTouched] = useState(false); + const leadMutation = useCreateLeadMutation(); + const phoneInvalid = leadTouched && !isValidPhone(phone); + const leadApiError = + typeof leadMutation.error?.message === "string" && leadMutation.error.message + ? leadMutation.error.message + : leadMutation.isError + ? "Не удалось отправить заявку, попробуйте ещё раз" + : null; + const canSubmitLead = isValidPhone(phone) && consent && !leadMutation.isPending; + + function handleLeadSubmit(e: React.FormEvent) { + e.preventDefault(); + setLeadTouched(true); + if (!isValidPhone(phone) || !consent || leadMutation.isPending) return; + leadMutation.mutate({ + phone: phone.trim(), + consent: true, + estimate_id: estimate.estimate_id, + source: "result", + }); + } return (
-
- {/* CTA — prominent primary (конверсия = преимущество над «мёртвым» PDF). */} - {/* Рендерим ТОЛЬКО при сконфигурированном лид-инбоксе (review #689) — иначе */} - {/* заявка с PII ушла бы на чужой/тестовый адрес. TODO(lead-flow): POST /lead. */} - {leadEnabled && ( - - - - - +
+ {leadMutation.isSuccess ? ( +

+ - Оставить заявку на трейд-ин - + Заявка принята, мы свяжемся с вами +

+ ) : ( +
+
+ + Оставить заявку на трейд-ин{brandName ? ` · ${brandName}` : ""} +
+
+ setPhone(e.target.value)} + disabled={leadMutation.isPending} + /> + +
+ + {phoneInvalid && ( +

+ Введите телефон (5–32 символа: цифры, +, пробелы, скобки, дефис) +

+ )} + {leadApiError &&

{leadApiError}

} +
)} +
+ +
diff --git a/tradein-mvp/frontend/src/components/trade-in/trade-in.css b/tradein-mvp/frontend/src/components/trade-in/trade-in.css index 9fa6ec8f..d2040092 100644 --- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css +++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css @@ -967,6 +967,70 @@ flex-direction: column; gap: 12px; } + + /* ── Форма заявки на трейд-ин (issue #2377) — телефон + согласие ФЗ-152 ── */ + .hero-lead { + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-2); + padding: 14px 16px; + } + .hero-lead-form { + display: flex; + flex-direction: column; + gap: 10px; + } + .hero-lead-head { + display: flex; + align-items: center; + gap: 8px; + font-size: 13.5px; + font-weight: 600; + color: var(--fg); + } + .hero-lead-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .hero-lead-row .control { + flex: 1 1 200px; + min-width: 0; + } + .hero-lead-submit { + flex: 0 0 auto; + white-space: nowrap; + } + .hero-lead-consent { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 11.5px; + line-height: 1.4; + color: var(--fg-2); + cursor: pointer; + } + .hero-lead-consent input { + width: 14px; + height: 14px; + margin-top: 2px; + flex-shrink: 0; + cursor: pointer; + } + .hero-lead-error { + margin: 0; + font-size: 12px; + color: var(--danger); + } + .hero-lead-success { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-size: 13.5px; + font-weight: 600; + color: var(--success, #16a34a); + } .hero-trust-actions { display: flex; flex-wrap: wrap; diff --git a/tradein-mvp/frontend/src/lib/trade-in-api.ts b/tradein-mvp/frontend/src/lib/trade-in-api.ts index a14435a4..fd00950f 100644 --- a/tradein-mvp/frontend/src/lib/trade-in-api.ts +++ b/tradein-mvp/frontend/src/lib/trade-in-api.ts @@ -18,6 +18,8 @@ import type { SellTimeSensitivityResponse, StreetDealsResponse, TradeInEstimateInput, + TradeInLeadInput, + TradeInLeadResponse, } from "@/types/trade-in"; const BASE = "/api/v1/trade-in"; @@ -37,6 +39,23 @@ export function useEstimateMutation() { }); } +/** + * POST /api/v1/trade-in/lead + * Контактная заявка (issue #2377 / backend #2376, PR #2390) — телефон + явное + * согласие ФЗ-152, опционально привязанные к оценке (estimate_id). 422 если + * consent !== true, 404 если estimate_id не найден. Notification (Telegram/ + * email) вне scope — только persist в trade_in_leads. + */ +export function useCreateLeadMutation() { + return useMutation({ + mutationFn: (input) => + apiFetch(`${BASE}/lead`, { + method: "POST", + body: JSON.stringify(input), + }), + }); +} + /** * GET /api/v1/trade-in/estimate/{id} * Fetches a previously computed estimate by UUID (for shareable links / PDF). diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 86bb7eec..b60aa368 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -443,6 +443,25 @@ export interface GeocodeSuggestResponse { items: GeocodeSuggestion[]; } +// ── Trade-in lead capture (endpoint: POST /trade-in/lead) ── +// Issue #2377 (sub-issue родителя #1971) — заменяет mailto-стаб в HeroTransparency +// на реальную заявку. Зеркалит TradeInLeadInput из +// tradein-mvp/backend/app/api/v1/lead.py (issue #2376 / PR #2390). +// consent: Literal[True] на бэкенде — 422 при consent !== true, поэтому тип +// здесь тоже сужен до `true` (не `boolean`), чтобы TS не давал собрать невалидный payload. +export interface TradeInLeadInput { + phone: string; // 5-32 символа, паттерн см. _PHONE_PATTERN в lead.py + estimate_id?: string | null; // UUID; 404 если не найден + consent: true; + source?: "result" | "landing"; // default 'result' на бэкенде +} + +export interface TradeInLeadResponse { + id: string; // UUID лида + created_at: string; // ISO datetime + status: string; // 'received' +} + // ── Location coefficient (endpoint: GET /trade-in/location-coef?estimate_id=&radius_m=) ── // #2045 BE-3 (backend) / #2317 (this FE wiring) — LocationDrawer + HeroBar «КОЭФ. // ЛОКАЦИИ». coef is an MVP heuristic (see backend app/services/location_coef.py -- 2.45.3