"use client"; /** * StreetDealsCard — «По вашей улице» — ДКП-сделки Росреестра по улице target адреса * с привязкой к историческим listings (Avito / Cian / Yandex / N1). * * Питается от GET /api/v1/trade-in/sales-vs-listings (PR L / #564 Phase 2). * Endpoint = superset предыдущего /street-deals: возвращает все pairs (LEFT JOIN * deal → listing) + linkage_rate. Aggregates (count / median / range) считаются * client-side по pairs[].deal_price_rub. * * Не рендерится если street не извлёкся или pairs.length = 0. */ import { useSalesVsListings } from "@/lib/trade-in-api"; import { openRosreestrWithAddress } from "@/lib/rosreestr"; import { safeUrl } from "@/lib/safeUrl"; import type { AggregatedEstimate, SalesListingPair } from "@/types/trade-in"; interface Props { estimate: AggregatedEstimate; } function fmtRub(v: number): string { return v.toLocaleString("ru-RU"); } function fmtDate(iso: string | null): string { if (!iso) return "—"; try { return new Date(iso).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit", }); } catch { return "—"; } } /** Median по числовому массиву (linear interpolation between centre items). */ function median(values: number[]): number { if (values.length === 0) return 0; const sorted = [...values].sort((a, b) => a - b); const mid = sorted.length / 2; if (Number.isInteger(mid)) { return (sorted[mid - 1] + sorted[mid]) / 2; } return sorted[Math.floor(mid)]; } /** Срок «за N дней до» / «через N дней после» сделки. */ function fmtDaysToDeal(days: number | null): string { if (days === null) return "—"; const abs = Math.abs(days); // Положительное = listing раньше сделки (типичный случай: «за 45 дней до»). // Отрицательное = listing появился позже сделки (отложенный парсинг, rare). return days >= 0 ? `за ${abs} дн до` : `через ${abs} дн после`; } /** Формат discount_pct со знаком и одной десятичной. */ function fmtDiscount(pct: number): string { const sign = pct > 0 ? "+" : pct < 0 ? "−" : ""; return `${sign}${Math.abs(pct).toFixed(1)}%`; } export function StreetDealsCard({ estimate }: Props) { const { data, isLoading, isError } = useSalesVsListings( estimate.target_address ?? null, estimate.area_m2 ?? null, estimate.rooms ?? null, ); if (isLoading || isError) return null; if (!data || !data.street || data.pairs.length === 0) return null; // Client-side aggregates по deal_price_rub / deal_price_per_m2 — endpoint // /sales-vs-listings не возвращает их (только linkage-связанные). const prices = data.pairs.map((p) => p.deal_price_rub); const ppm2Values = data.pairs.map((p) => p.deal_price_per_m2); const medianPpm2 = Math.round(median(ppm2Values)); const rangeLow = Math.min(...prices); const rangeHigh = Math.max(...prices); const rangeLowM = (rangeLow / 1_000_000).toFixed(1); const rangeHighM = (rangeHigh / 1_000_000).toFixed(1); const hasAnyListing = data.deals_with_listings > 0; const visiblePairs: SalesListingPair[] = data.pairs.slice(0, 10); return (
Секция · По вашей улице

ДКП-сделки на улице {data.street}

{hasAnyListing && (
{data.linkage_rate_pct.toFixed(0)}% сделок имеют исторический ASK {data.median_discount_pct !== null && ( <> {" · медианный торг "} 0 ? "discount-pos mono" : "mono" } > {fmtDiscount(data.median_discount_pct)} )}
)} {data.data_quality === "street_only" && (
Данные по улице, не по конкретному дому: привязать сделки ДКП к отдельному объявлению по этому адресу нельзя — показаны все сделки на улице.
)}
Период: {data.period_months} мес
источник: Росреестр + объявления
Сделок за {data.period_months} мес
{data.total_deals} шт
Медиана
{fmtRub(medianPpm2)} ₽/м²
Диапазон
{rangeLowM} – {rangeHighM} млн ₽
{hasAnyListing && } {visiblePairs.map((p) => { const safeListingUrl = safeUrl(p.listing_source_url); const hasListing = p.listing_id !== null && p.listing_price_rub !== null; return ( {hasAnyListing && ( )} ); })}
Адрес Площадь Цена сделки ₽/м² ДатаОбъявление до сделкиДействия
{p.deal_address} {p.deal_area_m2.toFixed(1)} м² {fmtRub(p.deal_price_rub)} ₽ {fmtRub(p.deal_price_per_m2)} {fmtDate(p.deal_date)} {hasListing ? ( ) : ( )}
Показано{" "} {visiblePairs.length} из{" "} {data.total_deals} сделок
); } // ── Listing badge cell ─────────────────────────────────────────────────────── interface ListingBadgeProps { source: string | null; url: string | null; priceRub: number | null; daysToDeal: number | null; discountPct: number | null; } function ListingBadge({ source, url, priceRub, daysToDeal, discountPct, }: ListingBadgeProps) { const sourceLabel = source ? source.toUpperCase() : "—"; // discount < 0 → продано дешевле выставленного (типичный торг, success). // discount > 0 → продано дороже выставленного (редко, обычно ошибка data, danger). const discountClass = discountPct === null ? "" : discountPct < 0 ? "discount-neg" : discountPct > 0 ? "discount-pos" : ""; const inner = ( <> {sourceLabel} {priceRub !== null ? `${fmtRub(priceRub)} ₽` : "—"} {fmtDaysToDeal(daysToDeal)} {discountPct !== null && ( <> {" · "} {fmtDiscount(discountPct)} )} ); if (url) { return ( {inner} ); } return {inner}; }