gendesign/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx
bot-backend b815b23655
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m10s
fix(tradein/ui): единый source-registry + честные знаменатели/лейблы источников (#2211)
- новый src/lib/source-registry.ts — один источник правды (SOURCES as const,
  канон сверен с estimator._canonical_sources); n1 live:false, убран из ростера
- HeroSummary: убран сфабрикованный «/7»; пустой sources_used → «источники не
  зафиксированы» вместо фейк-чипов; убрана выдуманная «классическая»; CV →
  честная метрика (реальный estimate.cv или лейбл «разброс»)
- ListingsCard: знаменатель убран совсем (sources_used включает оценочные
  источники — фиксированный знаменатель либо врёт, либо меньше числителя);
  fix невидимой rosreestr-точки (dot "ros"→"rosreestr", класса .ros нет в CSS)
- SourcesProgress: ростер из registry, мёртвый N1 убран
- EstimateForm: убран всегда-зелёный фейковый «● готов» cache-индикатор
- page.tsx: восстановленный отчёт показывает created_at, не new Date()
- types: +cv/source_counts/created_at (уже были в backend-схеме)
- инвариант: type-level (as const satisfies) + контракт-комментарий против
  хардкод-массивов источников

Refs #2211
2026-07-02 23:49:54 +03:00

378 lines
14 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";
/**
* ListingsCard — Секция 2 «Рынок» из mockup tradein.html.
* Counts strip + filters + price bar + table.
*/
import { useMemo } from "react";
import type { AggregatedEstimate, AnalogLot, CianPriceChangeStats } from "@/types/trade-in";
import { safeUrl } from "@/lib/safeUrl";
import { useEstimateCianPriceChanges } from "@/lib/trade-in-api";
import { openRosreestrWithAddress } from "@/lib/rosreestr";
import { sourceDot, sourceLabel } from "@/lib/source-registry";
interface Props {
estimate: AggregatedEstimate;
estimateId?: string;
}
function fmtRub(v: number): string {
return v.toLocaleString("ru-RU");
}
function fmtArea(v: number): string {
return v.toFixed(1);
}
/** Гуманизация свежести данных (#770 finding #31): мин → ч → дн. */
function humanizeAgo(min: number | null): string {
if (min === null || min < 0) return "—";
if (min < 60) return `${min} мин назад`;
if (min < 1440) return `${Math.round(min / 60)} ч назад`;
return `${Math.round(min / 1440)} дн назад`;
}
/** Extract Cian numeric ID from a source_url like https://ekb.cian.ru/sale/flat/123456789/ */
function extractCianId(url: string | null): string | null {
if (!url) return null;
const m = url.match(/\/sale\/flat\/(\d+)/);
return m ? m[1] : null;
}
export function ListingsCard({ estimate, estimateId }: Props) {
const priceChangesQuery = useEstimateCianPriceChanges(estimateId ?? null);
const priceChangeMap = useMemo<Map<string, CianPriceChangeStats>>(() => {
const map = new Map<string, CianPriceChangeStats>();
for (const stats of priceChangesQuery.data ?? []) {
map.set(stats.cian_id, stats);
}
return map;
}, [priceChangesQuery.data]);
const lots = estimate.analogs;
if (lots.length === 0) {
return (
<article className="card">
<div className="card-head">
<div>
<div className="section-kicker">Секция 2 · Рынок</div>
<h2>Объявления аналогичные квартиры в продаже</h2>
</div>
</div>
<div className="card-body">
<p style={{ color: "var(--muted)", textAlign: "center", padding: "24px 0" }}>
Нет объявлений-аналогов в радиусе 2 км.
</p>
</div>
</article>
);
}
// Считаем источники
const sourceCounts: Record<string, number> = {};
for (const l of lots) {
if (l.source) sourceCounts[l.source] = (sourceCounts[l.source] ?? 0) + 1;
}
// Price bar bounds
const prices = lots.map((l) => l.price_rub).sort((a, b) => a - b);
const lo = prices[0];
const hi = prices[prices.length - 1];
const m = estimate.median_price_rub;
const span = hi - lo;
const medPct = span > 0 ? Math.max(5, Math.min(95, ((m - lo) / span) * 100)) : 50;
const p25 = prices[Math.floor(prices.length * 0.25)];
const p75 = prices[Math.floor(prices.length * 0.75)];
const p25Pct = span > 0 ? Math.max(5, Math.min(95, ((p25 - lo) / span) * 100)) : 25;
const p75Pct = span > 0 ? Math.max(5, Math.min(95, ((p75 - lo) / span) * 100)) : 75;
// Площадь / годы / комнаты — для filter chips
const areas = lots.map((l) => l.area_m2);
const aMin = Math.min(...areas);
const aMax = Math.max(...areas);
const rooms = lots[0]?.rooms;
return (
<article className="card">
<div className="card-head">
<div>
<div className="section-kicker">Секция 2 · Рынок</div>
<h2>Объявления аналогичные квартиры в продаже</h2>
</div>
<div className="card-meta">
<div>
Обновлено:{" "}
<b className="mono">{humanizeAgo(estimate.data_freshness_minutes)}</b>
</div>
<div style={{ marginTop: 4 }}>
{/* #2211: без знаменателя — sources_used включает и оценочные
источники, любой фиксированный знаменатель либо врёт, либо
оказывается меньше числителя (консистентно с HeroSummary). */}
Источников: <b>{estimate.sources_used.length}</b>
</div>
</div>
</div>
<div className="count-strip">
<div className="count-cell">
<div className="label">Объявлений по аналогам</div>
<div className="value">
<span data-tnum>{estimate.n_analogs}</span>
<span className="unit">шт</span>
</div>
<div className="sub">из {estimate.sources_used.length} источников</div>
</div>
<div className="count-cell">
<div className="label">Медиана</div>
<div className="value">
{m > 0 ? (
<>
<span data-tnum>{(m / 1_000_000).toFixed(2)}</span>
<span className="unit">млн </span>
</>
) : (
<span data-tnum></span>
)}
</div>
<div className="sub">
{m > 0 ? `${estimate.median_price_per_m2.toLocaleString("ru-RU")} ₽/м²` : "нет данных"}
</div>
</div>
<div className="count-cell">
<div className="label">Диапазон P25P75</div>
<div className="value">
<span data-tnum>
{(estimate.range_low_rub / 1_000_000).toFixed(1)}
{(estimate.range_high_rub / 1_000_000).toFixed(1)}
</span>
<span className="unit">млн</span>
</div>
{/* #2211 находка 4: настоящий CV (σ/среднее ₽/м², #2043) когда бэкенд его
отдал; иначе честный «разброс» = ширина диапазона (НЕ коэффициент
вариации, поэтому без аббревиатуры CV). */}
<div className="sub">
{typeof estimate.cv === "number" && estimate.cv > 0 ? (
<abbr title="Коэффициент вариации цен аналогов (σ/среднее ₽/м²)">CV</abbr>
) : (
<abbr title="Ширина ценового диапазона относительно медианы (не коэффициент вариации)">
разброс
</abbr>
)}{" "}
{typeof estimate.cv === "number" && estimate.cv > 0
? `${(estimate.cv * 100).toFixed(1)}%`
: m > 0
? `${(((estimate.range_high_rub - estimate.range_low_rub) / m) * 100).toFixed(1)}%`
: "—"}
</div>
</div>
</div>
<div className="filters-row">
<span className="lbl">Фильтр</span>
<div className="filters">
<span className="chip">
<span className="k">Расстояние</span>
<span className="v"> 2 км</span>
</span>
{rooms !== undefined && (
<span className="chip">
<span className="k">Планировка</span>
<span className="v">{rooms === 0 ? "Студия" : `${rooms}`}</span>
</span>
)}
<span className="chip">
<span className="k">Площадь</span>
<span className="v">
{fmtArea(aMin)} {fmtArea(aMax)} м²
</span>
</span>
</div>
</div>
<div className="filters-row">
<span className="lbl">Источники</span>
<div className="sources">
{Object.entries(sourceCounts).map(([src, count]) => (
<span key={src} className="source-chip">
<span className={`src-dot ${sourceDot(src)}`} /> {sourceLabel(src)} ·{" "}
<span className="mono" style={{ color: "var(--muted)" }}>
{count}
</span>
</span>
))}
</div>
</div>
<div className="pricebar-block">
<div className="bar-head">
<span
className="bar-title"
style={{ color: "var(--fg-2)", fontSize: 12, letterSpacing: "0.04em" }}
>
ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ
{m > 0 ? ` · ${(m / 1_000_000).toFixed(2)} МЛН ₽ медиана` : ""}
</span>
</div>
<div className="pricebar">
<div className="axis">
<div className="range" style={{ left: "5%", right: "5%" }} />
<div className="median" style={{ left: `${medPct}%` }} />
</div>
<div className="endpoint" style={{ left: "5%" }}>
<div className="endpoint-val">{(lo / 1_000_000).toFixed(1)} млн</div>
<div className="endpoint-tick" />
</div>
<div className="endpoint" style={{ left: `${p25Pct}%` }} title="P25">
<div className="endpoint-val mono" style={{ fontWeight: 500, color: "var(--muted)" }}>
{(p25 / 1_000_000).toFixed(1)}
</div>
<div className="endpoint-tick" />
</div>
<div className="endpoint" style={{ left: `${p75Pct}%` }} title="P75">
<div className="endpoint-val mono" style={{ fontWeight: 500, color: "var(--muted)" }}>
{(p75 / 1_000_000).toFixed(1)}
</div>
<div className="endpoint-tick" />
</div>
<div className="endpoint" style={{ left: "95%" }}>
<div className="endpoint-val">{(hi / 1_000_000).toFixed(1)} млн</div>
<div className="endpoint-tick" />
</div>
<div className="median-label" style={{ left: `${medPct}%`, top: -2 }}>
{(m / 1_000_000).toFixed(2)} млн
</div>
<div className="exp left">P25 · {(p25 / 1_000_000).toFixed(1)} МЛН</div>
<div className="exp right">P75 · {(p75 / 1_000_000).toFixed(1)} МЛН</div>
</div>
</div>
<table className="dt" aria-label="Аналогичные объявления">
<thead>
<tr>
<th>Адрес</th>
<th>Источник</th>
<th className="num"> / м²</th>
<th className="num">Стоимость</th>
<th className="num">Дист.</th>
<th><span className="sr-only">Действия</span></th>
</tr>
</thead>
<tbody>
{lots.map((lot, i) => (
<AnalogRow
key={`${lot.source ?? ""}|${lot.address}|${lot.price_rub}|${i}`}
lot={lot}
priceChangeMap={priceChangeMap}
/>
))}
</tbody>
</table>
<div className="table-foot">
<span>
Показано <b style={{ color: "var(--fg)" }}>{lots.length}</b> из {estimate.n_analogs}{" "}
объявлений · отсортировано по расстоянию
</span>
</div>
</article>
);
}
function AnalogRow({
lot,
priceChangeMap,
}: {
lot: AnalogLot;
priceChangeMap: Map<string, CianPriceChangeStats>;
}) {
const src = lot.source ?? "";
const dot = sourceDot(src);
const label = sourceLabel(src);
const distance =
lot.distance_m === null
? "—"
: lot.distance_m < 1000
? `${lot.distance_m} м`
: `${(lot.distance_m / 1000).toFixed(1)} км`;
const externalUrl = safeUrl(lot.source_url);
const cianId = src === "cian" ? extractCianId(lot.source_url) : null;
const priceStats = cianId ? priceChangeMap.get(cianId) : undefined;
return (
<tr>
<td>
<div className="addr">
<span className="a-main">{lot.address}</span>
<span className="a-sub">
{fmtArea(lot.area_m2)} м² · {lot.rooms === 0 ? "студия" : `${lot.rooms}`}
{lot.floor !== null && lot.total_floors !== null
? ` · этаж ${lot.floor}/${lot.total_floors}`
: ""}
</span>
</div>
</td>
<td>
<span className="src-mini">
<span className={`src-dot ${dot}`} /> {label}
</span>
</td>
<td className="num">
{fmtRub(lot.price_per_m2)}
</td>
<td className="num">
{fmtRub(lot.price_rub)}
{priceStats && (
<span
style={{
fontSize: 11,
padding: "2px 6px",
borderRadius: 4,
background:
priceStats.last_diff_percent != null && priceStats.last_diff_percent < 0
? "#dcfce7"
: "#fee2e2",
color:
priceStats.last_diff_percent != null && priceStats.last_diff_percent < 0
? "#166534"
: "#991b1b",
marginLeft: 6,
}}
>
{priceStats.n_changes > 1 ? `${priceStats.n_changes}` : "↓1"}
{priceStats.last_diff_percent != null
? ` ${priceStats.last_diff_percent > 0 ? "+" : ""}${priceStats.last_diff_percent.toFixed(1)}%`
: ""}
</span>
)}
</td>
<td className="num">{distance}</td>
<td style={{ whiteSpace: "nowrap" }}>
{externalUrl && (
<a
href={externalUrl}
target="_blank"
rel="noopener noreferrer"
className="row-link"
aria-label="Открыть оригинал"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
</a>
)}
<button
type="button"
className="rosreestr-btn"
onClick={() => void openRosreestrWithAddress(lot.address)}
title="Скопировать адрес и открыть форму запроса выписки ЕГРН"
>
Росреестр
</button>
</td>
</tr>
);
}