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
Co-authored-by: bot-frontend <bot-frontend@gendsgn.local> Co-committed-by: bot-frontend <bot-frontend@gendsgn.local>
381 lines
13 KiB
TypeScript
381 lines
13 KiB
TypeScript
"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">Диапазон P25–P75</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>
|
||
);
|
||
}
|