feat(tradein/v2): replace mailto lead CTA with real form (#2377)
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m6s
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI / openapi-codegen-check (pull_request) Has been skipped
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m6s
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI / openapi-codegen-check (pull_request) Has been skipped
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]").
This commit is contained in:
parent
a5097c3a97
commit
5adf94357e
4 changed files with 206 additions and 35 deletions
|
|
@ -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">
|
||||
Введите телефон (5–32 символа: цифры, +, пробелы, скобки, дефис)
|
||||
</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" />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue