gendesign/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx
lekss361 07cbcd235b feat(tradein): cian price-change badges + split price-history chart
Backend:
- New GET /api/v1/trade-in/estimate/{id}/cian-price-changes — per-analog
  price change stats (n_changes, last_diff_pct, total_change_pct) из
  offer_price_history (cian playwright_history backfill, 268+ rows).
- house-analytics: PriceHistoryYearPoint gains source field; SQL теперь
  GROUP BY year, source → 2 серии для chart (avito_imv / yandex_valuation).

Frontend:
- ListingsCard показывает colored badge ↓N ±X.X% рядом с ценой cian-аналога
  если есть price changes (зелёный = снижение, красный = рост).
- PriceHistoryChart: pivot long→wide format client-side, 2 Line components
  (синий Avito #2563eb, оранжевый Яндекс #ea580c) + Legend, connectNulls.

Использует данные:
- offer_price_history (live cian price changes)
- house_placement_history.source разделение avito_imv vs yandex_valuation
2026-05-24 21:16:03 +03:00

350 lines
12 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";
interface Props {
estimate: AggregatedEstimate;
estimateId?: string;
}
const SOURCE_DOTS: Record<string, string> = {
cian: "cian",
avito: "avito",
yandex: "yandex",
domklik: "dom",
rosreestr: "ros",
n1: "n1",
};
const SOURCE_LABELS: Record<string, string> = {
cian: "Циан",
avito: "Avito",
yandex: "Я.Недв",
domklik: "ДомКлик",
rosreestr: "Росреестр",
n1: "N1.ru",
};
function fmtRub(v: number): string {
return v.toLocaleString("ru-RU");
}
function fmtArea(v: number): string {
return v.toFixed(1);
}
/** 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">
{estimate.data_freshness_minutes !== null
? `${estimate.data_freshness_minutes} мин назад`
: "—"}
</b>
</div>
<div style={{ marginTop: 4 }}>
Источников: <b>{estimate.sources_used.length} / 7</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">
<span data-tnum>{(m / 1_000_000).toFixed(2)}</span>
<span className="unit">млн </span>
</div>
<div className="sub">{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>
<div className="sub">CV {(((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 ${SOURCE_DOTS[src] ?? "dom"}`} /> {SOURCE_LABELS[src] ?? 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 / 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 />
</tr>
</thead>
<tbody>
{lots.map((lot, i) => (
<AnalogRow key={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 = SOURCE_DOTS[src] ?? "dom";
const label = SOURCE_LABELS[src] ?? 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>
{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>
)}
</td>
</tr>
);
}