"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, ConfidenceLevel } from "@/types/trade-in"; import { asHouseType, asRepairState } from "@/types/trade-in"; import { useActiveBrandSlug, useBrand } from "@/lib/useBrand"; import { HeroTransparency } from "./HeroTransparency"; 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(--warn)" }, low: { txt: "низкая", color: "var(--danger)" }, }; // #2002: человекочитаемые лейблы класса премиального дома. Ключи — как их отдаёт // бэкенд (lower-case RU). Неизвестный класс → капитализируем первую букву. const PREMIUM_CLASS_LABELS: Record = { премиум: "Премиум", элит: "Элит", бизнес: "Бизнес-класс", }; function premiumBadgeLabel(cls: string | null | undefined): string { if (!cls) return "Премиальный дом"; const known = PREMIUM_CLASS_LABELS[cls.trim().toLowerCase()]; if (known) return known; const t = cls.trim(); return t.charAt(0).toUpperCase() + t.slice(1); } 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); // Defensive depth (audit #1871 P1.2): ghost-anchor guard. Если legacy-строка / // rehydrate / контракт-drift отдаст confidence='high' при n_analogs=0, UI должен // показывать «низкая», а не «высокая · 0 объектов». Бэкенд уже не отдаёт high при // n_analogs=0, это второй слой защиты для отображения (сырое значение в analytics // не нужно). Caveat-текст приходит из estimate.confidence_explanation (backend). const effectiveConfidence: ConfidenceLevel = estimate.n_analogs === 0 ? "low" : estimate.confidence; const conf = CONF_LABELS[effectiveConfidence] ?? 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 = 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; // Пустая оценка (backend #697): нет аналогов/сделок → median<=0. Вместо // «0,00 млн ₽» в headline показываем явный empty-state. const insufficientData = estimate.insufficient_data || m <= 0; // ── Ожидаемая цена продажи (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); // ── #2002: сигналы «ручной оценки» + «премиального дома» ── // Старые/кешированные оценки эти поля не содержат → undefined ≈ false / []. const showManualReview = estimate.manual_review_recommended === true; const manualReviewReasons = estimate.manual_review_reasons ?? []; const showPremiumBadge = estimate.premium_building === true; const premiumLabel = premiumBadgeLabel(estimate.premium_building_class); // ── #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) && effectiveConfidence !== "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 } = {}; const ht = asHouseType(enrichHouseType); if (ht) patch.house_type = ht; const rs = asRepairState(enrichRepairState); if (rs) patch.repair_state = rs; 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}
{showPremiumBadge && ( {premiumLabel} )}
ЭТАЖ {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} дн. ) : ( нет данных )}
{/* ── #2002: баннер «рекомендуется ручная оценка». Рядом с headline-числом, amber/non-alarming (токены --accent-2). Не презентуем авто-оценку как твёрдую оферту, когда бэкенд считает её рискованной. */} {showManualReview && (
Рекомендуется ручная оценка

Автоматический расчёт по этому объекту менее надёжен — используйте его как ориентир, а не как окончательную цену. Для точной стоимости уточните оценку у специалиста.

{manualReviewReasons.length > 0 && (
    {manualReviewReasons.map((reason, i) => (
  • {reason}
  • ))}
)}
)} {/* ── Две равноправные hero-цены: «Выставить» (asking) + «Получить» (sold). ── При hasSold=false (старые оценки / пустая ratio) — одиночный layout только с «Выставить». Empty-state при insufficientData. */}
{insufficientData ? (
Оценка недоступна
Недостаточно данных для оценки

Рядом не нашлось достаточно сопоставимых объявлений и сделок, чтобы рассчитать цену. Уточните адрес или параметры объекта и попробуйте снова.

) : ( <> {/* Левая ячейка: «Выставить в объявлении» */}
Выставить в объявлении
{formatMln(m)} ₽
{formatMln(lo)} – {formatMln(hi)} ₽
{estimate.median_price_per_m2.toLocaleString("ru-RU")} ₽/м²
{/* Правая ячейка: «Получить (ожидаемая сделка)» — только при hasSold */} {hasSold && (
Получить (ожидаемая сделка) {showDiscount && ( −{discountPct}% )}
{formatMln(sold as number)} ₽
{typeof soldLo === "number" && typeof soldHi === "number" && (
{formatMln(soldLo)} – {formatMln(soldHi)} ₽
)} {typeof soldPerM2 === "number" && (
{soldPerM2.toLocaleString("ru-RU")} ₽/м²
)} {estimate.ratio_basis === "global_fallback" ? (
Ср.-городской коэффициент (мало сделок в сегменте)
) : estimate.ratio_basis === "per_rooms" ? (
Коэффициент по сделкам той же планировки
) : null}
)} )}
{/* ── Компактный ряд бенчмарков: Avito IMV · Cian · ДКП ── Вторичный контекст-якорь под hero, не конкурирует по размеру. */} {!insufficientData && (imvPrice !== null || estimate.cian_valuation?.sale_price_rub != null || showDkp) && (
{imvPrice !== null && (
Avito IMV {formatMln(imvPrice)} ₽
)} {estimate.cian_valuation?.sale_price_rub != null && (
Cian {formatMln(estimate.cian_valuation.sale_price_rub)} ₽ {estimate.cian_valuation.rent_price_rub != null && ( аренда {(estimate.cian_valuation.rent_price_rub / 1000).toFixed(0)} т.р./мес )}
)} {showDkp && dkp && (
ДКП · Росреестр {formatMln(dkp.median_ppm2 * dkpArea)} ₽ {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} сд.
)}
)} {/* ── Доверие + действия (web-преимущества над PDF): свежесть данных, collapsible «Как рассчитано», PDF-отчёт, CTA трейд-ин. Headline выше остаётся чистым — прозрачность вторична/collapsible. ── */} {/* ── Два сопоставимых ценовых ориентира рынка (РАЗНЫЕ источники) ── asking (объявления) vs реальные сделки ДКП. Оба — рыночный контекст под headline-итогом, поэтому одинакового веса. При insufficient_data (median=0) оба блока скрыты — иначе «0,00 млн ₽» (#748). */} {!insufficientData && ( <>
Рекомендованная цена в объявлении
{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} сделок
Росреестр · ДомКлик
); })()} {/* ── Задача 3: Бар «Ожидаемая цена сделки» (expected_sold) ── Показывается только если поле присутствует и > 0. Graceful при null. Визуально отличается: акцентный градиент (--viz-5/--viz-6) и пометка. */} {hasSold && (() => { // Строим бар либо из диапазона (low/high), либо из точечного значения ±0 span. const esLo = typeof soldLo === "number" && soldLo > 0 ? soldLo : (sold as number); const esHi = typeof soldHi === "number" && soldHi > 0 ? soldHi : (sold as number); const esM = sold as number; const esSpan = esHi - esLo; const esMedPct = esSpan > 0 ? Math.max(5, Math.min(95, ((esM - esLo) / esSpan) * 100)) : 50; return (
Ожидаемая цена сделки{" "} (прогноз) медиана · {formatMln(esM)} ₽
{formatMln(esLo)}
{formatMln(esHi)}
{formatMln(esM)} ₽
{typeof soldPerM2 === "number" && (
{soldPerM2.toLocaleString("ru-RU")} ₽/м²
)} {showDiscount && (
−{discountPct}% к объявлению
)}
); })()}
)} {/* ── Источники данных (#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 && ( )}
)}
); }