gendesign/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
lekss361 81dd103cb4 fix(tradein-ui): replace Tailwind with OKLCH tokens + a11y on disclaimer
Review-bot feedback on #517:
- tradein-mvp/frontend doesn't use Tailwind — classes (rounded-lg, bg-amber-50, …)
  rendered as plain unstyled text. Replace with inline styles using project's
  OKLCH design tokens (--accent-2, --accent-2-soft, --surface-2, --radius, --fg).
- Add role=status aria-live=polite on disclaimer for screen readers
- Move handleResubmit below resultData declaration (ESLint no-use-before-define)
2026-05-24 15:47:40 +03:00

401 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 } 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;
const m = estimate.median_price_rub;
const lo = estimate.range_low_rub;
const hi = estimate.range_high_rub;
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)}%
</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-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>512%</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>
);
}