feat(tradein): «По вашей улице» — StreetDealsCard на real ДКП-сделки Росреестра #556

Merged
lekss361 merged 1 commit from feat/tradein-street-deals-frontend into main 2026-05-24 20:37:30 +00:00
4 changed files with 174 additions and 0 deletions

View file

@ -23,6 +23,7 @@ import { HouseAnalyticsSection } from "@/components/trade-in/HouseAnalyticsSecti
import { PhotoUpload } from "@/components/trade-in/PhotoUpload";
import { ListingsCard } from "@/components/trade-in/ListingsCard";
import { DealsCard } from "@/components/trade-in/DealsCard";
import { StreetDealsCard } from "@/components/trade-in/StreetDealsCard";
import { OfferCard } from "@/components/trade-in/OfferCard";
import { TestPresets } from "@/components/trade-in/TestPresets";
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
@ -182,6 +183,7 @@ export default function TradeInPage() {
<PhotoUpload estimateId={resultData.estimate.estimate_id} />
<ListingsCard estimate={resultData.estimate} estimateId={currentEstimateId ?? undefined} />
<DealsCard estimate={resultData.estimate} />
<StreetDealsCard estimate={resultData.estimate} />
<OfferCard estimate={resultData.estimate} />
</>
) : (

View file

@ -0,0 +1,134 @@
"use client";
/**
* StreetDealsCard «По вашей улице» ДКП-сделки Росреестра по улице target адреса.
* Питается от GET /api/v1/trade-in/street-deals (PR #555).
* Не рендерится если street не извлёкся или count = 0.
*/
import { useStreetDeals } from "@/lib/trade-in-api";
import { openRosreestrWithAddress } from "@/lib/rosreestr";
import type { AggregatedEstimate, AnalogLot } 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 "—";
}
}
export function StreetDealsCard({ estimate }: Props) {
const { data, isLoading, isError } = useStreetDeals(
estimate.target_address ?? null,
estimate.area_m2 ?? null,
estimate.rooms ?? null,
);
if (isLoading || isError) return null;
if (!data || !data.street || data.count === 0) return null;
const rangeLowM = (data.range_low_rub / 1_000_000).toFixed(1);
const rangeHighM = (data.range_high_rub / 1_000_000).toFixed(1);
return (
<article className="card">
<div className="card-head">
<div>
<div className="section-kicker">Секция · По вашей улице</div>
<h2>ДКП-сделки на улице {data.street}</h2>
</div>
<div className="card-meta">
<div>
Период: <b className="mono">12 мес</b>
</div>
<div style={{ marginTop: 4 }}>источник: Росреестр (открытые данные)</div>
</div>
</div>
<div className="count-strip">
<div className="count-cell">
<div className="label">Сделок за 12 мес</div>
<div className="value">
<span data-tnum>{data.count}</span>
<span className="unit">шт</span>
</div>
</div>
<div className="count-cell">
<div className="label">Медиана</div>
<div className="value">
<span data-tnum>{fmtRub(data.median_price_per_m2)}</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" aria-label="Сделки по улице">
<thead>
<tr>
<th>Адрес</th>
<th className="num">Площадь</th>
<th className="num">Цена</th>
<th className="num">/м²</th>
<th className="num">Дата</th>
<th />
</tr>
</thead>
<tbody>
{data.deals.map((d: AnalogLot, i: number) => (
<tr key={i}>
<td>
<span className="addr">
<span className="a-main">{d.address}</span>
</span>
</td>
<td className="num">{d.area_m2.toFixed(1)} м²</td>
<td className="num">{fmtRub(d.price_rub)} </td>
<td className="num">{fmtRub(d.price_per_m2)}</td>
<td className="num">{fmtDate(d.listing_date)}</td>
<td>
<button
type="button"
className="rosreestr-btn"
onClick={() => void openRosreestrWithAddress(d.address)}
title="Скопировать адрес и открыть форму запроса выписки ЕГРН"
>
Росреестр
</button>
</td>
</tr>
))}
</tbody>
</table>
<div className="table-foot">
<span>
Показано{" "}
<b style={{ color: "var(--fg)" }}>{Math.min(data.deals.length, 10)}</b>{" "}
из <b style={{ color: "var(--fg)" }}>{data.count}</b> сделок
</span>
</div>
</article>
);
}

View file

@ -11,6 +11,7 @@ import type {
IMVBenchmarkResponse,
PlacementHistoryItem,
SellTimeSensitivityResponse,
StreetDealsResponse,
TradeInEstimateInput,
} from "@/types/trade-in";
@ -120,6 +121,29 @@ export function useEstimateHouseAnalytics(estimate_id: string | null) {
});
}
/**
* GET /api/v1/trade-in/street-deals
* ДКП-сделки Росреестра по улице target адреса. Per-street (open dataset
* без номера дома). Empty response если street не извлёкся.
*/
export function useStreetDeals(
address: string | null,
area_m2: number | null,
rooms: number | null,
) {
const params = new URLSearchParams();
if (address) params.set("address", address);
if (area_m2 !== null) params.set("area_m2", String(area_m2));
if (rooms !== null) params.set("rooms", String(rooms));
return useQuery<StreetDealsResponse>({
queryKey: ["trade-in", "street-deals", address, area_m2, rooms],
queryFn: () =>
apiFetch<StreetDealsResponse>(`${BASE}/street-deals?${params}`),
enabled: !!address && area_m2 !== null && rooms !== null,
staleTime: 5 * 60_000,
});
}
/**
* GET /api/v1/trade-in/estimate/{id}/sell-time-sensitivity
* Median exposure days bucketed by price premium (-5%, 0, +5%, +10%).

View file

@ -192,6 +192,20 @@ export interface HouseAnalyticsResponse {
kpi: HouseAnalyticsKpi;
}
// ── Street deals (endpoint: GET /trade-in/street-deals) ──
export interface StreetDealsResponse {
street: string | null; // "Космонавтов" / null если не извлёкся
period_from: string; // ISO date "2025-05-29"
period_to: string;
count: number; // all matching, не топ-10
median_price_rub: number;
median_price_per_m2: number;
range_low_rub: number;
range_high_rub: number;
deals: AnalogLot[]; // top-10 по deal_date DESC
}
// ── Sell-time sensitivity (endpoint: GET /estimate/{id}/sell-time-sensitivity) ──
export interface SellTimeBucket {