"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 = { panel: "Панельный", brick: "Кирпичный", monolith: "Монолит", monolith_brick: "Монолит-кирпич", other: "Другое", }; const REPAIR_LABELS: Record = { needs_repair: "Требует ремонта", standard: "Стандартный", good: "Хороший", excellent: "Евроремонт", }; const SOURCE_DOTS: Record = { cian: "cian", avito: "avito", yandex: "yandex", domklik: "dom", rosreestr: "rosreestr", n1: "n1", }; const SOURCE_LABELS: Record = { cian: "Циан", avito: "Avito", yandex: "Я.Недвижимость", domklik: "ДомКлик", rosreestr: "Росреестр", n1: "N1.ru", }; const CONF_LABELS: Record = { 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(""); const [enrichRepairState, setEnrichRepairState] = useState(""); // Фото первого аналога с картинкой — вместо пустого серого плейсхолдера. 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 (
{showMockDisclaimer && (
⚠️ Адрес распознан неточно — оценка может быть приближённой. Уточните адрес и пересчитайте для более точного результата.
)}
Секция 1 · Сводка

Анализ рынка и расчёт стоимости

Источник:{" "} агрегация {estimate.sources_used.length}/7
Достоверность · {conf.txt} · CV {cv.toFixed(1)}% {precisionBadge && ( {precisionBadge.text} )}
{heroPhoto && ( Фото похожего объекта { e.currentTarget.style.display = "none"; }} style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }} /> )}
{heroPhoto ? `фото аналога${estimate.sources_used[0] ? ` · ${estimate.sources_used[0]}` : ""}` : estimate.sources_used.length > 0 ? `${estimate.sources_used[0]} · ${estimate.n_analogs} аналогов` : "Нет фото"}
{estimate.target_address ?? input.address}
ЭТАЖ {floor}/{totalFloors} {yearBuilt ? ` · ${yearBuilt}` : ""}
{yearBuilt && (
Год постройки {yearBuilt}
)} {houseType && (
Тип дома {HOUSE_TYPE_LABELS[houseType] ?? houseType}
)}
Этаж {floor} / {totalFloors}
Площадь {areaM2} м²
Планировка {rooms === 0 ? "Студия" : `${rooms}-к`}, классическая
{repairState && (
Состояние {REPAIR_LABELS[repairState] ?? repairState}
)}
Балкон {hasBalcony ? "есть" : "нет"}
Аналогов {estimate.n_analogs}
Срок продажи {estDaysOnMarket !== null ? ( {estDaysOnMarket} дн. ) : ( нет данных )}
{/* ── ЕДИНЫЙ итог: «Ожидаемая цена сделки» (S2). Доминирующий headline. ── При наличии expected_sold показываем его как главный outcome (ниже запроса, с −X%). Без него (старые оценки / пустая ratio) — headline = asking-медиана, чтобы блок не оставался без главного числа. */}
{hasSold ? "Ожидаемая цена сделки" : "Рекомендованная цена"} {showDiscount && ( −{discountPct}% к объявлению )}
{formatMln(hasSold ? (sold as number) : m)} ₽
{hasSold && typeof soldLo === "number" && typeof soldHi === "number" && (
ожидаемый диапазон {formatMln(soldLo)} – {formatMln(soldHi)} ₽ {typeof soldPerM2 === "number" ? ` · ${soldPerM2.toLocaleString("ru-RU")} ₽/м²` : ""}
)} {hasSold && (

Реальные сделки проходят на 5–12% ниже цен в объявлениях — это ожидаемая цена сделки по данным ДКП Росреестра, а не цена запроса.

)}
{/* ── Два сопоставимых ценовых ориентира рынка (РАЗНЫЕ источники) ── asking (объявления) vs реальные сделки ДКП. Оба — рыночный контекст под headline-итогом, поэтому одинакового веса. */}
Рекомендованная цена в объявлении
{formatMln(m)} ₽
{formatMln(lo)} – {formatMln(hi)} ₽
{estimate.median_price_per_m2.toLocaleString("ru-RU")} ₽/м² · по объявлениям
{imvPrice !== null && (
Avito IMV: {formatMln(imvPrice)} ₽
)}
{showDkp && dkp && (
Цены по фактическим сделкам (ДКП · Росреестр)
{formatMln(dkp.median_ppm2 * dkpArea)} ₽
{formatMln(dkp.low_ppm2 * dkpArea)} – {formatMln(dkp.high_ppm2 * dkpArea)} ₽
{dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} ДКП за{" "} {dkp.period_months} мес
)}
Диапазон цен в объявлениях{" "} (без учёта ремонта) медиана · {formatMln(m)} ₽
{formatMln(lo)}
{formatMln(hi)}
{formatMln(m)} ₽
{estimate.median_price_per_m2.toLocaleString("ru-RU")} ₽/м²
{estimate.n_analogs} аналог.
{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 (
Диапазон цен по фактическим сделкам медиана · {formatMln(dM)} ₽
{formatMln(dLo)}
{formatMln(dHi)}
{formatMln(dM)} ₽
{estimate.actual_deals.length} сделок
Росреестр · ДомКлик
); })()}
{/* ── Источники данных (#648 IA, task #5) — какие источники участвовали ── */}
Источники
{(estimate.sources_used.length > 0 ? estimate.sources_used : ["avito", "cian", "yandex", "rosreestr"] ).map((src) => ( {" "} {SOURCE_LABELS[src] ?? src} ))}
{estimate.confidence_explanation && (
{estimate.confidence_explanation}
)} {/* 3 статичных explainer-карточки (#648 IA, всегда показываем). */}
Цены в объявлениях — это ожидания собственников. Реальные сделки проходят на 5–12% ниже по данным ДКП Росреестра.
Ремонт оценивается по состоянию, а не по вложенным суммам.{" "} Рынок платит за итоговый вид квартиры, а не за чеки на материалы.
Скрытые расходы при самостоятельной продаже — до 15% на торге, риелторе, нотариусе и аренде жилья на время сделки.
{showEnrichment && (

Уточните для повышения точности оценки:

{needsHouseType && ( )} {needsRepairState && ( )}
)}
); }