feat(tradein/v2): replace mailto lead CTA with real form (#2377) #2393

Merged
lekss361 merged 1 commit from fix/tradein-2377-lead-form into main 2026-07-04 08:03:01 +00:00
4 changed files with 206 additions and 35 deletions

View file

@ -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<string, string> = {
};
/**
* Лид-инбокс. Задаётся ТОЛЬКО через 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 (
<div className="hero-trust">
<div className="hero-trust-actions">
{/* CTA — prominent primary (конверсия = преимущество над «мёртвым» PDF). */}
{/* Рендерим ТОЛЬКО при сконфигурированном лид-инбоксе (review #689) — иначе */}
{/* заявка с PII ушла бы на чужой/тестовый адрес. TODO(lead-flow): POST /lead. */}
{leadEnabled && (
<a className="btn btn-accent hero-trust-cta" href={leadHref}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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" />
<div className="hero-lead">
{leadMutation.isSuccess ? (
<p className="hero-lead-success" role="status">
<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>
Оставить заявку на трейд-ин
</a>
Заявка принята, мы свяжемся с вами
</p>
) : (
<form className="hero-lead-form" onSubmit={handleLeadSubmit} noValidate>
<div className="hero-lead-head">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>Оставить заявку на трейд-ин{brandName ? ` · ${brandName}` : ""}</span>
</div>
<div className="hero-lead-row">
<input
className="control"
type="tel"
inputMode="tel"
autoComplete="tel"
placeholder="+7 900 000-00-00"
aria-label="Телефон для связи"
aria-invalid={phoneInvalid ? true : undefined}
value={phone}
onChange={(e) => setPhone(e.target.value)}
disabled={leadMutation.isPending}
/>
<button
type="submit"
className="btn btn-accent hero-lead-submit"
disabled={leadMutation.isPending}
aria-disabled={canSubmitLead ? undefined : true}
style={{ opacity: canSubmitLead ? 1 : 0.55 }}
>
{leadMutation.isPending ? "Отправляем…" : "Оставить заявку"}
</button>
</div>
<label className="hero-lead-consent">
<input
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
disabled={leadMutation.isPending}
/>
<span>
Согласен(-на) на обработку персональных данных в соответствии с Федеральным
законом «О персональных данных» 152-ФЗ
</span>
</label>
{phoneInvalid && (
<p className="hero-lead-error">
Введите телефон (532 символа: цифры, +, пробелы, скобки, дефис)
</p>
)}
{leadApiError && <p className="hero-lead-error">{leadApiError}</p>}
</form>
)}
</div>
<div className="hero-trust-actions">
<a className="btn btn-ghost hero-trust-pdf" href={pdfHref} target="_blank" rel="noopener noreferrer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />

View file

@ -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;

View file

@ -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<TradeInLeadResponse, Error, TradeInLeadInput>({
mutationFn: (input) =>
apiFetch<TradeInLeadResponse>(`${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).

View file

@ -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