"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 = { cian: "cian", avito: "avito", yandex: "yandex", domklik: "dom", rosreestr: "ros", n1: "n1", }; const SOURCE_LABELS: Record = { 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>(() => { const map = new Map(); 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 (
Секция 2 · Рынок

Объявления — аналогичные квартиры в продаже

Нет объявлений-аналогов в радиусе 2 км.

); } // Считаем источники const sourceCounts: Record = {}; 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 (
Секция 2 · Рынок

Объявления — аналогичные квартиры в продаже

Обновлено:{" "} {estimate.data_freshness_minutes !== null ? `${estimate.data_freshness_minutes} мин назад` : "—"}
Источников: {estimate.sources_used.length} / 7
Объявлений по аналогам
{estimate.n_analogs} шт
из {estimate.sources_used.length} источников
Медиана
{(m / 1_000_000).toFixed(2)} млн ₽
{estimate.median_price_per_m2.toLocaleString("ru-RU")} ₽/м²
Диапазон P25–P75
{(estimate.range_low_rub / 1_000_000).toFixed(1)}– {(estimate.range_high_rub / 1_000_000).toFixed(1)} млн
CV{" "} {m > 0 ? `${(((estimate.range_high_rub - estimate.range_low_rub) / m) * 100).toFixed(1)}%` : "—"}
Фильтр
Расстояние ≤ 2 км {rooms !== undefined && ( Планировка {rooms === 0 ? "Студия" : `${rooms}-к`} )} Площадь {fmtArea(aMin)} – {fmtArea(aMax)} м²
Источники
{Object.entries(sourceCounts).map(([src, count]) => ( {SOURCE_LABELS[src] ?? src} ·{" "} {count} ))}
ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ · {(m / 1_000_000).toFixed(2)} МЛН ₽ медиана
{(lo / 1_000_000).toFixed(1)} млн
{(p25 / 1_000_000).toFixed(1)}
{(p75 / 1_000_000).toFixed(1)}
{(hi / 1_000_000).toFixed(1)} млн
{(m / 1_000_000).toFixed(2)} млн ₽
P25 · {(p25 / 1_000_000).toFixed(1)} МЛН
P75 · {(p75 / 1_000_000).toFixed(1)} МЛН
{lots.map((lot, i) => ( ))}
Адрес Источник ₽ / м² Стоимость Дист.
Показано {lots.length} из {estimate.n_analogs}{" "} объявлений · отсортировано по расстоянию
); } function AnalogRow({ lot, priceChangeMap, }: { lot: AnalogLot; priceChangeMap: Map; }) { 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 (
{lot.address} {fmtArea(lot.area_m2)} м² · {lot.rooms === 0 ? "студия" : `${lot.rooms}-к`} {lot.floor !== null && lot.total_floors !== null ? ` · этаж ${lot.floor}/${lot.total_floors}` : ""}
{label} {fmtRub(lot.price_per_m2)} {fmtRub(lot.price_rub)} {priceStats && ( {priceStats.n_changes > 1 ? `↓${priceStats.n_changes}` : "↓1"} {priceStats.last_diff_percent != null ? ` ${priceStats.last_diff_percent > 0 ? "+" : ""}${priceStats.last_diff_percent.toFixed(1)}%` : ""} )} {distance} {externalUrl && ( )} ); }