Show «Ориентир запроса» (asking median) and «Ожидаемая цена продажи» (expected sold, S3 rosreestr ДКП) as two EQUAL-weight figures (same value typography; sold card gets a subtle accent tint only), each with range + ₽/м², plus a tasteful −N% discount chip (aria-labelled, Unicode minus) and an explainer. Graceful: expected_sold null/0 (old estimates / empty ratio table) → single asking figure, no empty card/rows. Tokens-only CSS, responsive. tsc --noEmit clean (TS strict); npm run build passes.
478 lines
21 KiB
TypeScript
478 lines
21 KiB
TypeScript
"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 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);
|
||
|
||
// 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>
|
||
|
||
<div className={`hero-prices${hasSold ? "" : " 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>
|
||
</div>
|
||
|
||
{hasSold && (
|
||
<div className="price-figure" data-kind="sold">
|
||
<div className="price-figure-label">
|
||
Ожидаемая цена продажи
|
||
{showDiscount && (
|
||
<span className="price-figure-delta" aria-label={`на ${discountPct}% ниже запроса`}>
|
||
−{discountPct}%
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="price-figure-value mono">{formatMln(sold as number)} ₽</div>
|
||
{typeof soldLo === "number" && typeof soldHi === "number" && (
|
||
<div className="price-figure-range mono">
|
||
{formatMln(soldLo)} – {formatMln(soldHi)} ₽
|
||
</div>
|
||
)}
|
||
{typeof soldPerM2 === "number" && (
|
||
<div className="price-figure-perm2 mono">
|
||
{soldPerM2.toLocaleString("ru-RU")} ₽/м²
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{hasSold && (
|
||
<p className="hero-prices-note">
|
||
<b>Запрос</b> — по чему выставлены сопоставимые квартиры в объявлениях.{" "}
|
||
<b>Ожидаемая цена продажи</b> — реалистичная цена сделки по данным ДКП Росреестра, обычно
|
||
ниже запроса.
|
||
</p>
|
||
)}
|
||
|
||
<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>
|
||
|
||
{estimate.confidence_explanation && (
|
||
<div className="hero-warnings">
|
||
<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>
|
||
<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>5–12%</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>
|
||
);
|
||
}
|