gendesign/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
bot-backend 9de867631b
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 8s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
feat(tradein/ui): честное раскрытие покрытия диапазона в hero (#2209)
Хинт диапазона expected_sold дополнен замеренной точностью: «фактическая
цена продажи попадает в диапазон в ~80% случаев (июль 2026)» — по живому
бэктесту на реальных ДКП Росреестра (n=276, coverage 81.5%, engine=full).
Раскрытие вместо молчаливой выдачи Q1-Q3 за прогнозный интервал —
acceptance-пункт «явно раскрыть точность/покрытие» из #2209.

Refs #2209
2026-07-03 00:23:03 +03:00

878 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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, ConfidenceLevel } from "@/types/trade-in";
import { asHouseType, asRepairState } from "@/types/trade-in";
import { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
import { sourceDot, sourceLabel } from "@/lib/source-registry";
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<string, string> = {
panel: "Панельный",
brick: "Кирпичный",
monolith: "Монолит",
monolith_brick: "Монолит-кирпич",
other: "Другое",
};
const REPAIR_LABELS: Record<string, string> = {
needs_repair: "Требует ремонта",
standard: "Стандартный",
good: "Хороший",
excellent: "Евроремонт",
};
const CONF_LABELS: Record<string, { txt: string; color: string }> = {
high: { txt: "высокая", color: "var(--success)" },
medium: { txt: "средняя", color: "var(--warn)" },
low: { txt: "низкая", color: "var(--danger)" },
};
// #2002: человекочитаемые лейблы класса премиального дома. Ключи — как их отдаёт
// бэкенд (lower-case RU). Неизвестный класс → капитализируем первую букву.
const PREMIUM_CLASS_LABELS: Record<string, string> = {
премиум: "Премиум",
элит: "Элит",
бизнес: "Бизнес-класс",
};
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 pluralSources(n: number): string {
const mod10 = n % 10;
const mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11) return "источник";
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return "источника";
return "источников";
}
/**
* Честная метрика разброса (#2211, находка 4).
* Прежний код называл «CV» величину (range_high range_low) / median — это ширина
* межквартильного диапазона, а НЕ коэффициент вариации. Теперь:
* • если бэкенд отдал настоящий cv (std/mean ₽/м², #2043) — показываем его как «CV»;
* • иначе (старые оценки без cv) — показываем ширину диапазона под честным лейблом
* «разброс», без аббревиатуры CV. Новая метрика не выдумывается.
*/
function dispersionMetric(
estimate: AggregatedEstimate,
): { label: string; value: string; title: string } | null {
if (typeof estimate.cv === "number" && estimate.cv > 0) {
return {
label: "CV",
value: `${(estimate.cv * 100).toFixed(1)}%`,
title: "Коэффициент вариации цен аналогов (σ/среднее ₽/м²)",
};
}
const m = estimate.median_price_rub;
if (m > 0 && estimate.range_high_rub > estimate.range_low_rub) {
const width = ((estimate.range_high_rub - estimate.range_low_rub) / m) * 100;
return {
label: "разброс",
value: `${width.toFixed(1)}%`,
title: "Ширина ценового диапазона относительно медианы (не коэффициент вариации)",
};
}
return null;
}
/** 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 dispersion = dispersionMetric(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<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 } = {};
const ht = asHouseType(enrichHouseType);
if (ht) patch.house_type = ht;
const rs = asRepairState(enrichRepairState);
if (rs) patch.repair_state = rs;
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,
}}
>
<span
aria-hidden="true"
style={{
display: "inline-block",
width: 8,
height: 8,
borderRadius: "50%",
background: "var(--warn)",
marginRight: 8,
verticalAlign: "middle",
}}
/>
Адрес распознан неточно оценка может быть приближённой.
Уточните адрес и пересчитайте для более точного результата.
</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 > 0
? `агрегация ${estimate.sources_used.length} ${pluralSources(estimate.sources_used.length)}`
: "источники не зафиксированы"}
</b>
</div>
<div style={{ marginTop: 4 }}>
Достоверность · <b style={{ color: conf.color }}>{conf.txt}</b>
{dispersion && (
<>
{" · "}
<abbr title={dispersion.title}>{dispersion.label}</abbr> {dispersion.value}
</>
)}
{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] ? ` · ${sourceLabel(estimate.sources_used[0])}` : ""}`
: estimate.sources_used.length > 0
? `${sourceLabel(estimate.sources_used[0])} · ${estimate.n_analogs} аналогов`
: "Нет фото"}
</div>
</div>
<div className="hero-meta">
<div className="hero-address">
{estimate.target_address ?? input.address}
</div>
{showPremiumBadge && (
<span
className="premium-badge"
title="Объект в премиальном жилом комплексе"
>
{premiumLabel}
</span>
)}
<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>
{/* ── #2002: баннер «рекомендуется ручная оценка». Рядом с headline-числом,
amber/non-alarming (токены --accent-2). Не презентуем авто-оценку как
твёрдую оферту, когда бэкенд считает её рискованной. */}
{showManualReview && (
<div
className="manual-review"
role="note"
aria-label="Рекомендуется ручная оценка"
>
<div className="manual-review__head">
<span className="manual-review__icon" aria-hidden="true">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</span>
<span className="manual-review__title">
Рекомендуется ручная оценка
</span>
</div>
<p className="manual-review__lead">
Автоматический расчёт по этому объекту менее надёжен используйте его
как ориентир, а не как окончательную цену. Для точной стоимости уточните
оценку у специалиста.
</p>
{manualReviewReasons.length > 0 && (
<ul className="manual-review__reasons">
{manualReviewReasons.map((reason, i) => (
<li key={`mr-${i}`}>{reason}</li>
))}
</ul>
)}
</div>
)}
{/* ── §1.1 «Одна цифра + честный разброс»: expected_sold — цифра-герой
(«оценка» / ожидаемая цена продажи), median — вторичный ориентир
«рекомендованная цена в объявлении», НЕ конкурирует за внимание.
При hasSold=false (старые оценки / пустая ratio) median остаётся
единственной честной цифрой. Empty-state при insufficientData. */}
<div
className={
!insufficientData && hasSold
? "hero-duo hero-duo--sold-primary"
: "hero-duo hero-duo--single"
}
>
{insufficientData ? (
<div className="hero-duo__cell">
<div className="hero-duo__label">Оценка недоступна</div>
<div className="headline-value-empty">Недостаточно данных для оценки</div>
<p className="headline-note">
Рядом не нашлось достаточно сопоставимых объявлений и сделок, чтобы
рассчитать цену. Уточните адрес или параметры объекта и попробуйте
снова.
</p>
</div>
) : hasSold ? (
<>
{/* Цифра-герой: «оценка» = ожидаемая цена продажи (expected_sold) */}
<div className="hero-duo__cell" data-role="sold">
<div className="hero-duo__label">
Оценка · ожидаемая цена продажи
{showDiscount && (
<span
className="hero-duo__delta"
title="Насколько ожидаемая цена сделки ниже цены в объявлении. Это разрыв «объявление → реальная сделка», а не скидка сервиса."
aria-label={`ожидаемая цена продажи на ${discountPct}% ниже цены в объявлении`}
>
{discountPct}% к объявлению
</span>
)}
</div>
<div className="hero-duo__value mono">{formatMln(sold as number)} </div>
{typeof soldLo === "number" && typeof soldHi === "number" && (
<div className="hero-duo__range mono">
рыночный разброс: {formatMln(soldLo)} {formatMln(soldHi)}
</div>
)}
{typeof soldPerM2 === "number" && (
<div className="hero-duo__perm2 mono">
{soldPerM2.toLocaleString("ru-RU")} /м²
</div>
)}
<p className="hero-duo__hint">
Диапазон отражает разброс цен по рынку, а не погрешность оценки.
{/* #2209: честное раскрытие точности — замеренное покрытие бэктестом
по реальным ДКП Росреестра (2026-07: n=276, coverage 81.5%).
Обновлять при существенном изменении baseline-метрик. */}{" "}
По нашему замеру на реальных сделках Росреестра фактическая цена
продажи попадает в диапазон в&nbsp;~80% случаев (июль&nbsp;2026).
</p>
{estimate.ratio_basis === "global_fallback" ? (
<div className="hero-duo__note">
Ср.-городской коэффициент (мало сделок в сегменте)
</div>
) : estimate.ratio_basis === "per_rooms" ? (
<div className="hero-duo__note">
Коэффициент по сделкам той же планировки
</div>
) : null}
</div>
{/* Вторичный ориентир: рекомендованная цена в объявлении (median) */}
<div className="hero-duo__cell" data-role="asking">
<div className="hero-duo__label">Рекомендованная цена в объявлении</div>
<div className="hero-duo__value mono">{formatMln(m)} </div>
<div className="hero-duo__range mono">
{formatMln(lo)} {formatMln(hi)}
</div>
<div className="hero-duo__perm2 mono">
{estimate.median_price_per_m2.toLocaleString("ru-RU")} /м²
</div>
</div>
</>
) : (
<div className="hero-duo__cell" data-role="asking">
<div className="hero-duo__label">Рекомендованная цена в объявлении</div>
<div className="hero-duo__value mono">{formatMln(m)} </div>
<div className="hero-duo__range mono">
{formatMln(lo)} {formatMln(hi)}
</div>
<div className="hero-duo__perm2 mono">
{estimate.median_price_per_m2.toLocaleString("ru-RU")} /м²
</div>
</div>
)}
</div>
{/* ── Компактный ряд бенчмарков: Avito IMV · Cian · ДКП ──
Вторичный контекст-якорь под hero, не конкурирует по размеру. */}
{!insufficientData && (imvPrice !== null || estimate.cian_valuation?.sale_price_rub != null || showDkp) && (
<div className="hero-benchmarks">
{imvPrice !== null && (
<div className="bench-chip">
<span className="bench-chip__src">Avito <abbr title="Information Market Value — оценка рыночной стоимости от Avito">IMV</abbr></span>
<span className="bench-chip__val mono">{formatMln(imvPrice)} </span>
</div>
)}
{estimate.cian_valuation?.sale_price_rub != null && (
<div className="bench-chip">
<span className="bench-chip__src">Cian</span>
<span className="bench-chip__val mono">
{formatMln(estimate.cian_valuation.sale_price_rub)}
</span>
{estimate.cian_valuation.rent_price_rub != null && (
<span className="bench-chip__sub mono">
аренда {(estimate.cian_valuation.rent_price_rub / 1000).toFixed(0)} т.р./мес
</span>
)}
</div>
)}
{showDkp && dkp && (
<div className="bench-chip">
<span className="bench-chip__src"><abbr title="Договор купли-продажи (реальные сделки Росреестра)">ДКП</abbr> · Росреестр</span>
<span className="bench-chip__val mono">
{formatMln(dkp.median_ppm2 * dkpArea)}
</span>
<span className="bench-chip__sub mono">
{dkp.median_ppm2.toLocaleString("ru-RU")} /м² · {dkp.count} сд.
</span>
</div>
)}
</div>
)}
{/* ── Доверие + действия (web-преимущества над PDF): свежесть данных,
collapsible «Как рассчитано», PDF-отчёт, CTA трейд-ин. Headline выше
остаётся чистым — прозрачность вторична/collapsible. ── */}
<HeroTransparency
estimate={estimate}
brandSlug={activeBrandSlug}
brandName={brand?.name ?? null}
/>
{/* ── Два сопоставимых ценовых ориентира рынка (РАЗНЫЕ источники) ──
asking (объявления) vs реальные сделки ДКП. Оба — рыночный контекст
под headline-итогом, поэтому одинакового веса.
При insufficient_data (median=0) оба блока скрыты — иначе «0,00 млн ₽» (#748). */}
{!insufficientData && (
<>
<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 <abbr title="Information Market Value — оценка рыночной стоимости от Avito">IMV</abbr>: {formatMln(imvPrice)}
</div>
)}
</div>
{showDkp && dkp && (
<div className="price-figure" data-kind="dkp">
<div className="price-figure-label">
Цены по фактическим сделкам (<abbr title="Договор купли-продажи (реальные сделки Росреестра)">ДКП</abbr> · Росреестр)
</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} <abbr title="Договор купли-продажи (реальные сделки Росреестра)">ДКП</abbr> за{" "}
{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>
);
})()}
{/* ── Задача 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 (
<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(esM)} </span>
</div>
<div className="pricebar">
<div className="axis">
<div
className="range"
style={{
left: "5%",
right: "5%",
background:
"linear-gradient(90deg,var(--viz-5,#a78bfa),var(--viz-6,#7c3aed))",
}}
/>
<div className="median" style={{ left: `${esMedPct}%` }} />
</div>
<div className="endpoint" style={{ left: "5%" }}>
<div className="endpoint-val">{formatMln(esLo)}</div>
<div className="endpoint-tick" />
</div>
<div className="endpoint" style={{ left: "95%" }}>
<div className="endpoint-val">{formatMln(esHi)}</div>
<div className="endpoint-tick" />
</div>
<div className="median-label" style={{ left: `${esMedPct}%`, top: -2 }}>
{formatMln(esM)}
</div>
{typeof soldPerM2 === "number" && (
<div className="exp left">
{soldPerM2.toLocaleString("ru-RU")} /м²
</div>
)}
{showDiscount && (
<div className="exp right" style={{ color: "var(--danger)" }}>
{discountPct}% к объявлению
</div>
)}
</div>
</div>
);
})()}
</div>
</>
)}
{/* ── Источники данных (#648 IA, task #5) — какие источники участвовали ──
#2211 находка 2: пустой sources_used → честный текст, а НЕ фейковые чипы
avito/cian/yandex/rosreestr, которых в оценке не было. Чипы строятся
только из фактического sources_used (лейблы/цвета — из source-registry). */}
<div className="filters-row" style={{ padding: "14px 22px 0" }}>
<span className="lbl">Источники</span>
<div className="sources">
{estimate.sources_used.length > 0 ? (
estimate.sources_used.map((src) => (
<span key={src} className="source-chip">
<span className={`src-dot ${sourceDot(src)}`} /> {sourceLabel(src)}
</span>
))
) : (
<span style={{ color: "var(--muted-2)" }}>источники не зафиксированы</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> по данным <abbr title="Договор купли-продажи (реальные сделки Росреестра)">ДКП</abbr> Росреестра.
</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>
);
}