gendesign/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx
bot-frontend de6afc99fa
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m35s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 37s
fix(tradein): a11y follow-up — sr-only th-текст + heading-order (#835) (#845)
Co-authored-by: bot-frontend <bot-frontend@gendsgn.local>
Co-committed-by: bot-frontend <bot-frontend@gendsgn.local>
2026-05-30 21:13:19 +00:00

381 lines
13 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";
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);
}
/** Гуманизация свежести данных (#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 }}>
Источников: <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">
{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>
<div className="sub">
CV{" "}
{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 ${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 > 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 = 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 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>
);
}