gendesign/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
bot-backend f43d593cf6
All checks were successful
Deploy Trade-In / changes (push) Successful in 4s
Deploy Trade-In / test (push) Successful in 23s
Deploy Trade-In / build-backend (push) Successful in 43s
Deploy Trade-In / build-frontend (push) Successful in 1m37s
Deploy Trade-In / deploy (push) Successful in 36s
feat(tradein): brand-by-account + result IA redesign (distinct asking/sale prices) (#683)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-05-30 06:45:14 +00:00

573 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/* eslint-disable @next/next/no-img-element -- фото аналога с внешнего CDN, next/image не нужен */
/**
* HeroSummary — Секция 1 «Сводка» из mockup tradein.html.
* Показывает медиану + достоверность CV + параметры объекта + 2 ценовых бара (объявления / сделки).
*/
import { useState } from "react";
import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState } from "@/types/trade-in";
interface Props {
estimate: AggregatedEstimate;
input: TradeInEstimateInput;
onResubmit: (patch: { house_type?: HouseType; repair_state?: RepairState }) => void;
isResubmitting?: boolean;
}
const HOUSE_TYPE_LABELS: Record<string, string> = {
panel: "Панельный",
brick: "Кирпичный",
monolith: "Монолит",
monolith_brick: "Монолит-кирпич",
other: "Другое",
};
const REPAIR_LABELS: Record<string, string> = {
needs_repair: "Требует ремонта",
standard: "Стандартный",
good: "Хороший",
excellent: "Евроремонт",
};
const SOURCE_DOTS: Record<string, string> = {
cian: "cian",
avito: "avito",
yandex: "yandex",
domklik: "dom",
rosreestr: "rosreestr",
n1: "n1",
};
const SOURCE_LABELS: Record<string, string> = {
cian: "Циан",
avito: "Avito",
yandex: "Я.Недвижимость",
domklik: "ДомКлик",
rosreestr: "Росреестр",
n1: "N1.ru",
};
const CONF_LABELS: Record<string, { txt: string; color: string }> = {
high: { txt: "высокая", color: "var(--success)" },
medium: { txt: "средняя", color: "var(--success)" },
low: { txt: "низкая", color: "var(--danger)" },
};
function formatMln(rub: number): string {
return `${(rub / 1_000_000).toFixed(2).replace(".", ",")} млн`;
}
function calcCv(estimate: AggregatedEstimate): number {
// CV = (P75 - P25) / median * 100 (наш range_high - range_low = P25-P75 пара)
const m = estimate.median_price_rub;
if (m === 0) return 0;
return ((estimate.range_high_rub - estimate.range_low_rub) / m) * 100;
}
/** Returns true when address looks like a non-residential geocode fallback */
function isMockAddress(estimate: AggregatedEstimate): boolean {
const addr = estimate.target_address ?? "";
if (/^(Склад|Гараж|Подсобка|Нежилое),\s/i.test(addr)) return true;
if (estimate.confidence_explanation?.startsWith("address_not_geocoded")) return true;
return false;
}
export function HeroSummary({ estimate, input, onResubmit, isResubmitting = false }: Props) {
const cv = calcCv(estimate);
const conf = CONF_LABELS[estimate.confidence] ?? CONF_LABELS.low;
// Бейдж точности гео-привязки адреса (DaData qc_geo). null/undefined → ничего не рисуем.
const precisionBadge =
estimate.address_precision === "house"
? { text: "адрес: до дома", className: "precision-badge precision-house" }
: estimate.address_precision === "street"
? { text: "адрес: до улицы", className: "precision-badge precision-street" }
: estimate.address_precision === "approximate"
? { text: "адрес: приблизительно", className: "precision-badge precision-approx" }
: null;
const m = estimate.median_price_rub;
const lo = estimate.range_low_rub;
const hi = estimate.range_high_rub;
// ── Ожидаемая цена продажи (S3). Рисуем второй блок только при валидном числе:
// null/undefined/0 (старые оценки или пустая ratio-таблица) → одиночный layout. ──
const sold = estimate.expected_sold_price_rub;
const hasSold = typeof sold === "number" && sold > 0;
const soldLo = estimate.expected_sold_range_low_rub;
const soldHi = estimate.expected_sold_range_high_rub;
const soldPerM2 = estimate.expected_sold_per_m2;
// Скидка запрос→продажа: из ratio при наличии, иначе из самих чисел. Только если ≥1%.
const discountPct = hasSold
? Math.round(
(typeof estimate.asking_to_sold_ratio === "number" && estimate.asking_to_sold_ratio > 0
? 1 - estimate.asking_to_sold_ratio
: 1 - (sold as number) / m) * 100,
)
: 0;
const showDiscount = hasSold && discountPct >= 1;
const showMockDisclaimer = isMockAddress(estimate);
// ── #651: Avito IMV anchor — реальная рыночная оценка дома. Маркер у цены. ──
const imv = estimate.avito_imv;
const imvPrice =
imv && typeof imv.recommended_price === "number" && imv.recommended_price > 0
? imv.recommended_price
: null;
// ── #652: коридор реальных ДКП-сделок (advisory). ₽/м² → млн через площадь. ──
const dkp = estimate.dkp_corridor;
const dkpArea = estimate.area_m2 ?? input.area_m2 ?? 0;
const showDkp =
!!dkp && dkp.count > 0 && dkpArea > 0 && dkp.low_ppm2 > 0 && dkp.high_ppm2 > 0;
// Progressive enrichment state
const needsHouseType = estimate.house_type === null;
const needsRepairState = estimate.repair_state === null;
const showEnrichment =
(needsHouseType || needsRepairState) && estimate.confidence !== "low";
const [enrichHouseType, setEnrichHouseType] = useState<string>("");
const [enrichRepairState, setEnrichRepairState] = useState<string>("");
// Фото первого аналога с картинкой — вместо пустого серого плейсхолдера.
const heroPhoto = estimate.analogs.find((a) => a.photo_url)?.photo_url ?? null;
// Расчёт ширины для price bar (50% = середина): медиана внутри min/max
const span = hi - lo;
const medianPctRaw = span > 0 ? ((m - lo) / span) * 100 : 50;
const medianPct = Math.max(5, Math.min(95, medianPctRaw));
// Параметры квартиры берём из estimate (надёжно при восстановлении по ?id=),
// с откатом на форму input — у свежей оценки оба источника совпадают.
const floor = estimate.floor ?? input.floor;
const totalFloors = estimate.total_floors ?? input.total_floors;
const areaM2 = estimate.area_m2 ?? input.area_m2;
const rooms = estimate.rooms ?? input.rooms;
const yearBuilt = estimate.year_built ?? input.year_built;
const houseType = estimate.house_type ?? input.house_type;
const repairState = estimate.repair_state ?? input.repair_state;
const hasBalcony = estimate.has_balcony ?? input.has_balcony;
const estDaysOnMarket = estimate.est_days_on_market;
function handleEnrichSubmit(e: React.FormEvent) {
e.preventDefault();
const patch: { house_type?: HouseType; repair_state?: RepairState } = {};
if (enrichHouseType) patch.house_type = enrichHouseType as HouseType;
if (enrichRepairState) patch.repair_state = enrichRepairState as RepairState;
onResubmit(patch);
}
return (
<article className="card hero-card">
{showMockDisclaimer && (
<div
role="status"
aria-live="polite"
style={{
margin: "16px 16px 0",
padding: "12px 16px",
border: "1px solid var(--accent-2)",
background: "var(--accent-2-soft)",
color: "var(--fg)",
borderRadius: "var(--radius)",
fontSize: 13,
lineHeight: 1.4,
}}
>
Адрес распознан неточно оценка может быть приближённой.
Уточните адрес и пересчитайте для более точного результата.
</div>
)}
<div className="card-head">
<div>
<div className="section-kicker">Секция 1 · Сводка</div>
<h2>Анализ рынка и расчёт стоимости</h2>
</div>
<div className="card-meta">
<div>
Источник:{" "}
<b>
агрегация {estimate.sources_used.length}/7
</b>
</div>
<div style={{ marginTop: 4 }}>
Достоверность · <b style={{ color: conf.color }}>{conf.txt}</b> · CV {cv.toFixed(1)}%
{precisionBadge && (
<span className={precisionBadge.className} title="Точность определения адреса">
{precisionBadge.text}
</span>
)}
</div>
</div>
</div>
<div className="hero-top">
<div className="hero-photo" role="img" aria-label="Фото квартиры-аналога">
{heroPhoto && (
<img
src={heroPhoto}
alt="Фото похожего объекта"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
/>
)}
<div className="photo-meta">
{heroPhoto
? `фото аналога${estimate.sources_used[0] ? ` · ${estimate.sources_used[0]}` : ""}`
: estimate.sources_used.length > 0
? `${estimate.sources_used[0]} · ${estimate.n_analogs} аналогов`
: "Нет фото"}
</div>
</div>
<div className="hero-meta">
<div className="hero-address">
{estimate.target_address ?? input.address}
</div>
<div className="hero-cad">
ЭТАЖ {floor}/{totalFloors}
{yearBuilt ? ` · ${yearBuilt}` : ""}
</div>
<div className="meta-grid">
{yearBuilt && (
<div className="meta-row">
<span className="k">Год постройки</span>
<span className="v mono">{yearBuilt}</span>
</div>
)}
{houseType && (
<div className="meta-row">
<span className="k">Тип дома</span>
<span className="v">{HOUSE_TYPE_LABELS[houseType] ?? houseType}</span>
</div>
)}
<div className="meta-row">
<span className="k">Этаж</span>
<span className="v mono">
{floor} / {totalFloors}
</span>
</div>
<div className="meta-row">
<span className="k">Площадь</span>
<span className="v mono">{areaM2} м²</span>
</div>
<div className="meta-row">
<span className="k">Планировка</span>
<span className="v">
{rooms === 0 ? "Студия" : `${rooms}`}, классическая
</span>
</div>
{repairState && (
<div className="meta-row">
<span className="k">Состояние</span>
<span className="v">{REPAIR_LABELS[repairState] ?? repairState}</span>
</div>
)}
<div className="meta-row">
<span className="k">Балкон</span>
<span className="v">{hasBalcony ? "есть" : "нет"}</span>
</div>
<div className="meta-row">
<span className="k">Аналогов</span>
<span className="v mono">{estimate.n_analogs}</span>
</div>
<div className="meta-row">
<span className="k">Срок продажи</span>
{estDaysOnMarket !== null ? (
<span className="v mono">{estDaysOnMarket} дн.</span>
) : (
<span className="v" style={{ color: "var(--muted)" }}>нет данных</span>
)}
</div>
</div>
</div>
</div>
{/* ── ЕДИНЫЙ итог: «Ожидаемая цена сделки» (S2). Доминирующий headline. ──
При наличии expected_sold показываем его как главный outcome (ниже
запроса, с X%). Без него (старые оценки / пустая ratio) — headline =
asking-медиана, чтобы блок не оставался без главного числа. */}
<div className="hero-headline">
<div className="headline-label">
{hasSold ? "Ожидаемая цена сделки" : "Рекомендованная цена"}
{showDiscount && (
<span
className="headline-delta"
aria-label={`на ${discountPct}% ниже рекомендованной цены в объявлении`}
>
{discountPct}% к объявлению
</span>
)}
</div>
<div className="headline-value mono">
{formatMln(hasSold ? (sold as number) : m)}
</div>
{hasSold && typeof soldLo === "number" && typeof soldHi === "number" && (
<div className="headline-range mono">
ожидаемый диапазон {formatMln(soldLo)} {formatMln(soldHi)}
{typeof soldPerM2 === "number"
? ` · ${soldPerM2.toLocaleString("ru-RU")} ₽/м²`
: ""}
</div>
)}
{hasSold && (
<p className="headline-note">
Реальные сделки проходят на <b>512% ниже</b> цен в объявлениях это
ожидаемая цена сделки по данным ДКП Росреестра, а не цена запроса.
</p>
)}
</div>
{/* ── Два сопоставимых ценовых ориентира рынка (РАЗНЫЕ источники) ──
asking (объявления) vs реальные сделки ДКП. Оба — рыночный контекст
под headline-итогом, поэтому одинакового веса. */}
<div className={`hero-prices${showDkp ? "" : " single"}`}>
<div className="price-figure" data-kind="asking">
<div className="price-figure-label">Рекомендованная цена в объявлении</div>
<div className="price-figure-value mono">{formatMln(m)} </div>
<div className="price-figure-range mono">
{formatMln(lo)} {formatMln(hi)}
</div>
<div className="price-figure-perm2 mono">
{estimate.median_price_per_m2.toLocaleString("ru-RU")} /м² · по объявлениям
</div>
{imvPrice !== null && (
<div
className="price-figure-imv mono"
title="Индекс Market Value Avito по этому дому — реальный рыночный якорь"
style={{ marginTop: 6, fontSize: 12, color: "var(--muted)" }}
>
Avito IMV: {formatMln(imvPrice)}
</div>
)}
</div>
{showDkp && dkp && (
<div className="price-figure" data-kind="dkp">
<div className="price-figure-label">
Цены по фактическим сделкам (ДКП · Росреестр)
</div>
<div className="price-figure-value mono">
{formatMln(dkp.median_ppm2 * dkpArea)}
</div>
<div className="price-figure-range mono">
{formatMln(dkp.low_ppm2 * dkpArea)} {formatMln(dkp.high_ppm2 * dkpArea)}
</div>
<div className="price-figure-perm2 mono">
{dkp.median_ppm2.toLocaleString("ru-RU")} /м² · {dkp.count} ДКП за{" "}
{dkp.period_months} мес
</div>
</div>
)}
</div>
<div className="hero-bars">
<div className="bar-block">
<div className="bar-head">
<span className="bar-title">
Диапазон цен в объявлениях{" "}
<span style={{ color: "var(--muted-2)" }}>(без учёта ремонта)</span>
</span>
<span className="bar-cv">медиана · {formatMln(m)} </span>
</div>
<div className="pricebar">
<div className="axis">
<div className="range" style={{ left: "5%", right: "5%" }} />
<div className="median" style={{ left: `${medianPct}%` }} />
</div>
<div className="endpoint" style={{ left: "5%" }}>
<div className="endpoint-val">{formatMln(lo)}</div>
<div className="endpoint-tick" />
</div>
<div className="endpoint" style={{ left: "95%" }}>
<div className="endpoint-val">{formatMln(hi)}</div>
<div className="endpoint-tick" />
</div>
<div className="median-label" style={{ left: `${medianPct}%`, top: -2 }}>
{formatMln(m)}
</div>
<div className="exp left">{estimate.median_price_per_m2.toLocaleString("ru-RU")} /м²</div>
<div className="exp right">{estimate.n_analogs} аналог.</div>
</div>
</div>
{estimate.actual_deals.length > 0 && (() => {
const dealsPrices = estimate.actual_deals.map((d) => d.price_rub);
const dLo = Math.min(...dealsPrices);
const dHi = Math.max(...dealsPrices);
const dM = dealsPrices.sort((a, b) => a - b)[Math.floor(dealsPrices.length / 2)];
const dSpan = dHi - dLo;
const dMedPct = dSpan > 0 ? Math.max(5, Math.min(95, ((dM - dLo) / dSpan) * 100)) : 50;
return (
<div className="bar-block">
<div className="bar-head">
<span className="bar-title">Диапазон цен по фактическим сделкам</span>
<span className="bar-cv">медиана · {formatMln(dM)} </span>
</div>
<div className="pricebar">
<div className="axis">
<div
className="range"
style={{
left: "5%",
right: "5%",
background: "linear-gradient(90deg,var(--viz-3),var(--viz-4))",
}}
/>
<div className="median" style={{ left: `${dMedPct}%` }} />
</div>
<div className="endpoint" style={{ left: "5%" }}>
<div className="endpoint-val">{formatMln(dLo)}</div>
<div className="endpoint-tick" />
</div>
<div className="endpoint" style={{ left: "95%" }}>
<div className="endpoint-val">{formatMln(dHi)}</div>
<div className="endpoint-tick" />
</div>
<div className="median-label" style={{ left: `${dMedPct}%`, top: -2 }}>
{formatMln(dM)}
</div>
<div className="exp left">{estimate.actual_deals.length} сделок</div>
<div className="exp right">Росреестр · ДомКлик</div>
</div>
</div>
);
})()}
</div>
{/* ── Источники данных (#648 IA, task #5) — какие источники участвовали ── */}
<div className="filters-row" style={{ padding: "14px 22px 0" }}>
<span className="lbl">Источники</span>
<div className="sources">
{(estimate.sources_used.length > 0
? estimate.sources_used
: ["avito", "cian", "yandex", "rosreestr"]
).map((src) => (
<span key={src} className="source-chip">
<span className={`src-dot ${SOURCE_DOTS[src] ?? "dom"}`} />{" "}
{SOURCE_LABELS[src] ?? src}
</span>
))}
</div>
</div>
<div className="hero-warnings">
{estimate.confidence_explanation && (
<div className="warn-row">
<span className="ic">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</span>
<div>{estimate.confidence_explanation}</div>
</div>
)}
{/* 3 статичных explainer-карточки (#648 IA, всегда показываем). */}
<div className="warn-row">
<span className="ic">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</span>
<div>
<b>Цены в объявлениях это ожидания собственников.</b> Реальные сделки
проходят на <b>512% ниже</b> по данным ДКП Росреестра.
</div>
</div>
<div className="warn-row">
<span className="ic">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</span>
<div>
<b>Ремонт оценивается по состоянию, а не по вложенным суммам.</b>{" "}
Рынок платит за итоговый вид квартиры, а не за чеки на материалы.
</div>
</div>
<div className="warn-row">
<span className="ic">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</span>
<div>
<b>Скрытые расходы при самостоятельной продаже до 15%</b> на торге,
риелторе, нотариусе и аренде жилья на время сделки.
</div>
</div>
</div>
{showEnrichment && (
<div
style={{
margin: "16px 16px 0",
padding: 16,
border: "1px solid var(--border, #e5e7eb)",
background: "var(--surface-2, #f8fafc)",
borderRadius: "var(--radius, 8px)",
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
<p style={{ fontSize: 13, fontWeight: 500, margin: 0 }}>
Уточните для повышения точности оценки:
</p>
<form onSubmit={handleEnrichSubmit}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 10, marginBottom: 12 }}>
{needsHouseType && (
<select
className="control"
value={enrichHouseType}
onChange={(e) => setEnrichHouseType(e.target.value)}
aria-label="Тип дома"
style={{ flex: "1 1 160px", minWidth: 140 }}
>
<option value="">Тип дома</option>
<option value="panel">Панельный</option>
<option value="brick">Кирпичный</option>
<option value="monolith">Монолит</option>
<option value="monolith_brick">Монолит-кирпич</option>
<option value="other">Другое</option>
</select>
)}
{needsRepairState && (
<select
className="control"
value={enrichRepairState}
onChange={(e) => setEnrichRepairState(e.target.value)}
aria-label="Состояние ремонта"
style={{ flex: "1 1 160px", minWidth: 140 }}
>
<option value="">Состояние</option>
<option value="needs_repair">Требует ремонта</option>
<option value="standard">Стандартный</option>
<option value="good">Хороший</option>
<option value="excellent">Евроремонт</option>
</select>
)}
</div>
<button
type="submit"
className="btn btn-primary"
disabled={isResubmitting || (!enrichHouseType && !enrichRepairState)}
style={{ opacity: isResubmitting || (!enrichHouseType && !enrichRepairState) ? 0.55 : 1 }}
>
{isResubmitting ? "Пересчитываем…" : "Пересчитать"}
</button>
</form>
</div>
)}
</article>
);
}