diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index 74256518..617524de 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -8,6 +8,8 @@ */ import { useState } from "react"; import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState } from "@/types/trade-in"; +import { useActiveBrandSlug, useBrand } from "@/lib/useBrand"; +import { HeroTransparency } from "./HeroTransparency"; interface Props { estimate: AggregatedEstimate; @@ -77,6 +79,9 @@ function isMockAddress(estimate: AggregatedEstimate): boolean { export function HeroSummary({ estimate, input, onResubmit, isResubmitting = false }: Props) { const cv = calcCv(estimate); 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 → ничего не рисуем. const precisionBadge = @@ -319,6 +324,15 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals )} + {/* ── Доверие + действия (web-преимущества над PDF): свежесть данных, + collapsible «Как рассчитано», PDF-отчёт, CTA трейд-ин. Headline выше + остаётся чистым — прозрачность вторична/collapsible. ── */} + + {/* ── Два сопоставимых ценовых ориентира рынка (РАЗНЫЕ источники) ── asking (объявления) vs реальные сделки ДКП. Оба — рыночный контекст под headline-итогом, поэтому одинакового веса. */} diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx new file mode 100644 index 00000000..7daefc74 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx @@ -0,0 +1,173 @@ +"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 = { + high: "высокая достоверность", + medium: "средняя достоверность", + low: "низкая достоверность", +}; + +/** + * Контакт для lead-стаба. Настраивается через NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL; + * fallback совпадает с backend `settings.contact_email`. + */ +const CONTACT_EMAIL = + process.env.NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL ?? "erginrajpopxbe@outlook.com"; + +/** + * Эвристика уровня сопоставимых объектов из 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); + + // 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 ( +
+
+ {/* CTA — prominent primary (конверсия = преимущество над «мёртвым» PDF) */} + {/* TODO(lead-flow): заменить mailto-стаб на POST /api/v1/trade-in/lead, когда появится backend. */} + + + + + + + Оставить заявку на трейд-ин + + + + + + + + + Скачать PDF-отчёт + +
+ + {fresh && ( +

+

+ )} + +
+ + Как рассчитано + {band} + + +
+
    + {tier && ( +
  • + Сопоставление + {tier} +
  • + )} +
  • + Аналогов в выборке + {estimate.n_analogs} +
  • +
  • + Достоверность оценки + {band} +
  • +
+ {estimate.confidence_explanation && !estimate.confidence_explanation.startsWith("address_not_geocoded") && ( +

{estimate.confidence_explanation}

+ )} +
+
+
+ ); +} 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 bdf8d738..19491cb2 100644 --- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css +++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css @@ -874,6 +874,116 @@ 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 ============================================================ */ diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index c72f6efd..03f520ba 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -121,6 +121,7 @@ export interface AggregatedEstimate { target_lon: number | null; sources_used: string[]; // ['avito', 'cian', 'rosreestr'] data_freshness_minutes: number | null; // «обновлено N минут назад» + last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional) est_days_on_market: number | null; // прогноз срока продажи // address_precision — точность гео-привязки адреса (из DaData qc_geo): // «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы),