gendesign/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
lekss361 3187419eb8
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m53s
Deploy Trade-In / deploy (push) Successful in 52s
feat(tradein/ui): manual-review banner + premium-building badge on estimate (#2002)
2026-06-27 19:09:46 +00:00

826 lines
38 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 { 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 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(--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 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<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}/7
</b>
</div>
<div style={{ marginTop: 4 }}>
Достоверность · <b style={{ color: conf.color }}>{conf.txt}</b> · <abbr title="Коэффициент вариации (разброс цен аналогов)">CV</abbr> {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>
{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>
)}
{/* ── Две равноправные hero-цены: «Выставить» (asking) + «Получить» (sold). ──
При hasSold=false (старые оценки / пустая ratio) — одиночный layout
только с «Выставить». Empty-state при insufficientData. */}
<div className={`hero-duo${hasSold && !insufficientData ? "" : " 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>
) : (
<>
{/* Левая ячейка: «Выставить в объявлении» */}
<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>
{/* Правая ячейка: «Получить (ожидаемая сделка)» — только при hasSold */}
{hasSold && (
<div className="hero-duo__cell" data-role="sold">
<div className="hero-duo__label">
Получить (ожидаемая сделка)
{showDiscount && (
<span
className="hero-duo__delta"
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>
)}
{estimate.ratio_basis === "global_fallback" ? (
<div className="hero-duo__note">
Ср.-городской коэффициент (мало сделок в сегменте)
</div>
) : estimate.ratio_basis === "per_rooms" ? (
<div className="hero-duo__note">
Коэффициент по сделкам той же планировки
</div>
) : null}
</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) — какие источники участвовали ── */}
<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> по данным <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>
);
}