feat(tradein): trust + actions (how-calculated, freshness, PDF, lead CTA) in result header #689
4 changed files with 303 additions and 0 deletions
|
|
@ -8,6 +8,8 @@
|
||||||
*/
|
*/
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState } from "@/types/trade-in";
|
import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState } from "@/types/trade-in";
|
||||||
|
import { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
|
||||||
|
import { HeroTransparency } from "./HeroTransparency";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
estimate: AggregatedEstimate;
|
estimate: AggregatedEstimate;
|
||||||
|
|
@ -77,6 +79,9 @@ function isMockAddress(estimate: AggregatedEstimate): boolean {
|
||||||
export function HeroSummary({ estimate, input, onResubmit, isResubmitting = false }: Props) {
|
export function HeroSummary({ estimate, input, onResubmit, isResubmitting = false }: Props) {
|
||||||
const cv = calcCv(estimate);
|
const cv = calcCv(estimate);
|
||||||
const conf = CONF_LABELS[estimate.confidence] ?? CONF_LABELS.low;
|
const conf = CONF_LABELS[estimate.confidence] ?? CONF_LABELS.low;
|
||||||
|
// Brand-aware PDF + lead-тема. useActiveBrandSlug (merged) reuse, brand name из /brand.
|
||||||
|
const activeBrandSlug = useActiveBrandSlug();
|
||||||
|
const { data: brand } = useBrand();
|
||||||
|
|
||||||
// Бейдж точности гео-привязки адреса (DaData qc_geo). null/undefined → ничего не рисуем.
|
// Бейдж точности гео-привязки адреса (DaData qc_geo). null/undefined → ничего не рисуем.
|
||||||
const precisionBadge =
|
const precisionBadge =
|
||||||
|
|
@ -319,6 +324,15 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Доверие + действия (web-преимущества над PDF): свежесть данных,
|
||||||
|
collapsible «Как рассчитано», PDF-отчёт, CTA трейд-ин. Headline выше
|
||||||
|
остаётся чистым — прозрачность вторична/collapsible. ── */}
|
||||||
|
<HeroTransparency
|
||||||
|
estimate={estimate}
|
||||||
|
brandSlug={activeBrandSlug}
|
||||||
|
brandName={brand?.name ?? null}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* ── Два сопоставимых ценовых ориентира рынка (РАЗНЫЕ источники) ──
|
{/* ── Два сопоставимых ценовых ориентира рынка (РАЗНЫЕ источники) ──
|
||||||
asking (объявления) vs реальные сделки ДКП. Оба — рыночный контекст
|
asking (объявления) vs реальные сделки ДКП. Оба — рыночный контекст
|
||||||
под headline-итогом, поэтому одинакового веса. */}
|
под headline-итогом, поэтому одинакового веса. */}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HeroTransparency — блок «доверие + действия» в HEADER результата трейд-ин.
|
||||||
|
*
|
||||||
|
* Web-преимущества над статичным PDF:
|
||||||
|
* 1. «Как рассчитано» — collapsible (details/summary): уровень аналогов
|
||||||
|
* (парсится из confidence_explanation), n_analogs, человекочитаемый band
|
||||||
|
* достоверности и полный текст объяснения. Доверие appraiser-grade, on demand.
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Тема — через brand-CSS-vars (--accent…), responsive, всё graceful при null-полях.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AggregatedEstimate } from "@/types/trade-in";
|
||||||
|
import { API_BASE_URL } from "@/lib/api";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
estimate: AggregatedEstimate;
|
||||||
|
/** Активный brand slug (useActiveBrandSlug) — для brand-aware PDF + темы письма. */
|
||||||
|
brandSlug: string | null;
|
||||||
|
/** Имя бренда (Brand.name) для темы заявки. null → generic. */
|
||||||
|
brandName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONF_BAND: Record<string, string> = {
|
||||||
|
high: "высокая достоверность",
|
||||||
|
medium: "средняя достоверность",
|
||||||
|
low: "низкая достоверность",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Лид-инбокс. Задаётся ТОЛЬКО через env NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL.
|
||||||
|
* БЕЗ fallback: если не задан → CTA «оставить заявку» скрывается (leadEnabled),
|
||||||
|
* чтобы заявка с PII не ушла на чужой/тестовый адрес (review PR #689).
|
||||||
|
*/
|
||||||
|
const CONTACT_EMAIL = (process.env.NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL ?? "").trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Эвристика уровня сопоставимых объектов из confidence_explanation.
|
||||||
|
* Бэкенд кладёт туда формулировку якорного tier'а; вытаскиваем человекочитаемую.
|
||||||
|
* Null → ничего не показываем (graceful).
|
||||||
|
*/
|
||||||
|
function compsTier(explanation: string | null): string | null {
|
||||||
|
if (!explanation) return null;
|
||||||
|
const t = explanation.toLowerCase();
|
||||||
|
if (t.includes("тот же дом") || t.includes("того же дома") || t.includes("этом же доме"))
|
||||||
|
return "по аналогам того же дома";
|
||||||
|
if (t.includes("≤500") || t.includes("<=500") || t.includes("500 м") || t.includes("окружени"))
|
||||||
|
return "по окружению ≤500 м";
|
||||||
|
if (t.includes("район") || t.includes("микрорайон")) return "по аналогам района";
|
||||||
|
if (t.includes("город")) return "по аналогам города";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «27 мая 2026, 14:30» */
|
||||||
|
function formatRuDateTime(d: Date): string {
|
||||||
|
return new Intl.DateTimeFormat("ru-RU", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Момент актуальности данных: предпочитаем last_scraped_at (ISO), иначе
|
||||||
|
* now − data_freshness_minutes. Невалидно/нет данных → null (строку не рисуем).
|
||||||
|
*/
|
||||||
|
function freshnessMoment(estimate: AggregatedEstimate): Date | null {
|
||||||
|
if (estimate.last_scraped_at) {
|
||||||
|
const d = new Date(estimate.last_scraped_at);
|
||||||
|
if (!Number.isNaN(d.getTime())) return d;
|
||||||
|
}
|
||||||
|
if (typeof estimate.data_freshness_minutes === "number" && estimate.data_freshness_minutes >= 0) {
|
||||||
|
return new Date(Date.now() - estimate.data_freshness_minutes * 60_000);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeroTransparency({ estimate, brandSlug, brandName }: Props) {
|
||||||
|
const band = CONF_BAND[estimate.confidence] ?? CONF_BAND.low;
|
||||||
|
const tier = compsTier(estimate.confidence_explanation);
|
||||||
|
const fresh = freshnessMoment(estimate);
|
||||||
|
// 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)}`;
|
||||||
|
|
||||||
|
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" />
|
||||||
|
</svg>
|
||||||
|
Оставить заявку на трейд-ин
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<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" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="12" y1="18" x2="12" y2="12" />
|
||||||
|
<polyline points="9 15 12 18 15 15" />
|
||||||
|
</svg>
|
||||||
|
Скачать PDF-отчёт
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fresh && (
|
||||||
|
<p className="hero-trust-freshness">
|
||||||
|
<span className="dot" aria-hidden="true" />
|
||||||
|
Данные актуальны на <b>{formatRuDateTime(fresh)}</b>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<details className="hero-trust-how">
|
||||||
|
<summary>
|
||||||
|
<span className="how-title">Как рассчитано</span>
|
||||||
|
<span className="how-band">{band}</span>
|
||||||
|
<svg className="how-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
<div className="how-body">
|
||||||
|
<ul className="how-facts">
|
||||||
|
{tier && (
|
||||||
|
<li>
|
||||||
|
<span className="k">Сопоставление</span>
|
||||||
|
<span className="v">{tier}</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
<li>
|
||||||
|
<span className="k">Аналогов в выборке</span>
|
||||||
|
<span className="v mono">{estimate.n_analogs}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="k">Достоверность оценки</span>
|
||||||
|
<span className="v">{band}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{estimate.confidence_explanation && !estimate.confidence_explanation.startsWith("address_not_geocoded") && (
|
||||||
|
<p className="how-explanation">{estimate.confidence_explanation}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -874,6 +874,116 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
HERO TRUST + ACTIONS — свежесть, «как рассчитано», PDF, CTA
|
||||||
|
(web-преимущества над статичным PDF). Тема через brand --accent.
|
||||||
|
============================================================ */
|
||||||
|
.hero-trust {
|
||||||
|
padding: 14px 22px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.hero-trust-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.hero-trust-cta {
|
||||||
|
flex: 1 1 240px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.hero-trust-pdf {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
}
|
||||||
|
.hero-trust-freshness {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--fg-2);
|
||||||
|
}
|
||||||
|
.hero-trust-freshness .dot {
|
||||||
|
width: 7px; height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--success, #16a34a);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in oklch, var(--success, #16a34a) 22%, transparent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.hero-trust-freshness b { color: var(--fg); font-weight: 600; }
|
||||||
|
|
||||||
|
.hero-trust-how {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hero-trust-how > summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--fg);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.hero-trust-how > summary::-webkit-details-marker { display: none; }
|
||||||
|
.hero-trust-how .how-title { font-weight: 600; }
|
||||||
|
.hero-trust-how .how-band {
|
||||||
|
padding: 2px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: color-mix(in oklch, var(--accent) 12%, transparent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
}
|
||||||
|
.hero-trust-how .how-chevron {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--muted);
|
||||||
|
transition: transform .15s ease;
|
||||||
|
}
|
||||||
|
.hero-trust-how[open] .how-chevron { transform: rotate(180deg); }
|
||||||
|
.hero-trust-how .how-body {
|
||||||
|
padding: 4px 14px 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.hero-trust-how .how-facts {
|
||||||
|
list-style: none;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
.hero-trust-how .how-facts li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.hero-trust-how .how-facts .k { color: var(--muted); }
|
||||||
|
.hero-trust-how .how-facts .v { color: var(--fg); font-weight: 500; text-align: right; }
|
||||||
|
.hero-trust-how .how-facts .v.mono { font-family: var(--font-mono); }
|
||||||
|
.hero-trust-how .how-explanation {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--fg-2);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.hero-trust-cta, .hero-trust-pdf { flex: 1 1 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
FILTER + SOURCE chips
|
FILTER + SOURCE chips
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,7 @@ export interface AggregatedEstimate {
|
||||||
target_lon: number | null;
|
target_lon: number | null;
|
||||||
sources_used: string[]; // ['avito', 'cian', 'rosreestr']
|
sources_used: string[]; // ['avito', 'cian', 'rosreestr']
|
||||||
data_freshness_minutes: number | null; // «обновлено N минут назад»
|
data_freshness_minutes: number | null; // «обновлено N минут назад»
|
||||||
|
last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional)
|
||||||
est_days_on_market: number | null; // прогноз срока продажи
|
est_days_on_market: number | null; // прогноз срока продажи
|
||||||
// address_precision — точность гео-привязки адреса (из DaData qc_geo):
|
// address_precision — точность гео-привязки адреса (из DaData qc_geo):
|
||||||
// «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы),
|
// «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue