gendesign/tradein-mvp/frontend/src/components/trade-in/StreetDealsCard.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

290 lines
10 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";
/**
* 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 (
<article className="card">
<div className="card-head">
<div>
<div className="section-kicker">Секция · По вашей улице</div>
<h2>ДКП-сделки на улице {data.street}</h2>
{hasAnyListing && (
<div className="linkage-hint">
<b className="mono">{data.linkage_rate_pct.toFixed(0)}%</b> сделок имеют
исторический ASK
{data.median_discount_pct !== null && (
<>
{" · медианный торг "}
<b
className={
data.median_discount_pct < 0
? "discount-neg mono"
: data.median_discount_pct > 0
? "discount-pos mono"
: "mono"
}
>
{fmtDiscount(data.median_discount_pct)}
</b>
</>
)}
</div>
)}
{data.data_quality === "street_only" && (
<div className="linkage-hint">
Данные по улице, не по конкретному дому: привязать сделки ДКП к
отдельному объявлению по этому адресу нельзя показаны все сделки
на улице.
</div>
)}
</div>
<div className="card-meta">
<div>
Период: <b className="mono">{data.period_months} мес</b>
</div>
<div style={{ marginTop: 4 }}>источник: Росреестр + объявления</div>
</div>
</div>
<div className="count-strip">
<div className="count-cell">
<div className="label">Сделок за {data.period_months} мес</div>
<div className="value">
<span data-tnum>{data.total_deals}</span>
<span className="unit">шт</span>
</div>
</div>
<div className="count-cell">
<div className="label">Медиана</div>
<div className="value">
<span data-tnum>{fmtRub(medianPpm2)}</span>
<span className="unit">/м²</span>
</div>
</div>
<div className="count-cell">
<div className="label">Диапазон</div>
<div className="value">
<span data-tnum>
{rangeLowM} {rangeHighM}
</span>
<span className="unit">млн </span>
</div>
</div>
</div>
<table className="dt dt-with-asks" aria-label="Сделки по улице с историческим ASK">
<thead>
<tr>
<th>Адрес</th>
<th className="num">Площадь</th>
<th className="num">Цена сделки</th>
<th className="num">/м²</th>
<th className="num">Дата</th>
{hasAnyListing && <th>Объявление до сделки</th>}
<th><span className="sr-only">Действия</span></th>
</tr>
</thead>
<tbody>
{visiblePairs.map((p) => {
const safeListingUrl = safeUrl(p.listing_source_url);
const hasListing = p.listing_id !== null && p.listing_price_rub !== null;
return (
<tr key={p.deal_id}>
<td>
<span className="addr">
<span className="a-main">{p.deal_address}</span>
</span>
</td>
<td className="num">{p.deal_area_m2.toFixed(1)} м²</td>
<td className="num">{fmtRub(p.deal_price_rub)} </td>
<td className="num">{fmtRub(p.deal_price_per_m2)}</td>
<td className="num">{fmtDate(p.deal_date)}</td>
{hasAnyListing && (
<td className="listing-cell">
{hasListing ? (
<ListingBadge
source={p.listing_source}
url={safeListingUrl}
priceRub={p.listing_price_rub}
daysToDeal={p.days_listing_to_deal}
discountPct={p.discount_pct}
/>
) : (
<span className="listing-empty"></span>
)}
</td>
)}
<td>
<button
type="button"
className="rosreestr-btn"
onClick={() => void openRosreestrWithAddress(p.deal_address)}
title="Скопировать адрес и открыть форму запроса выписки ЕГРН"
>
Росреестр
</button>
</td>
</tr>
);
})}
</tbody>
</table>
<div className="table-foot">
<span>
Показано{" "}
<b style={{ color: "var(--fg)" }}>{visiblePairs.length}</b> из{" "}
<b style={{ color: "var(--fg)" }}>{data.total_deals}</b> сделок
</span>
</div>
</article>
);
}
// ── 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 = (
<>
<span className="listing-source-badge">{sourceLabel}</span>
<span className="listing-price mono">
{priceRub !== null ? `${fmtRub(priceRub)}` : "—"}
</span>
<span className="listing-meta">
{fmtDaysToDeal(daysToDeal)}
{discountPct !== null && (
<>
{" · "}
<b className={`${discountClass} mono`}>{fmtDiscount(discountPct)}</b>
</>
)}
</span>
</>
);
if (url) {
return (
<a
className="listing-link"
href={url}
target="_blank"
rel="noopener noreferrer"
title={`Открыть объявление на ${sourceLabel.toLowerCase()}`}
>
{inner}
</a>
);
}
return <span className="listing-link is-static">{inner}</span>;
}