From 267585e3121dd34bfca1b347ac52c44c748e5511 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 28 Jun 2026 15:46:07 +0300 Subject: [PATCH] feat(tradein/v2): wire overlays History/Analytics/Sources to real API (#2039 #2040 #2041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FE-2/3/4 on the FE-1 mappers architecture. Adds mapHistory/mapAnalytics/mapSources (+helpers) to mappers.ts; HistoryView/AnalyticsView/SourcesView accept composite data props (fixture default); SectionOverlay forwards overlay data; page.tsx adds useEstimatePlacementHistory/useSalesVsListings/useEstimateSellTimeSensitivity and feeds mapped data. FE-1 main dashboard untouched. - History: house sales (placement-history); ДКП KPI/header (street-deals + sales-vs-listings); per-deal rows with REAL addr/date/listing-source/discount. - Analytics: KPIs (экспозиция/торг/доля снятых), 4 sell-time tiers, price-history polylines computed from year×source, detail scatter. - Sources: ad rows from analogs (real source/distance/url via safeUrl), deal rows from actual_deals (real source/tier/date), market KPIs. - Un-hardcoded fabricated cells: DKP addr/date/source, deal date, deals footer count. - FE-approx (TODO BE-1 #2043): CV, per-source counts, days_on_market->listing_date. Verified: next build green (/v2 37.8 kB); tsx harness ran the 3 overlay mappers vs real estimate 701795d8 (houseSales/KPIs/sellTime/priceHistory/scatter/ad+deal rows correct). code-reviewer APPROVE after honesty fixes. --- tradein-mvp/frontend/src/app/v2/page.tsx | 46 +- .../components/trade-in/v2/AnalyticsView.tsx | 86 ++- .../components/trade-in/v2/HistoryView.tsx | 43 +- .../components/trade-in/v2/SectionOverlay.tsx | 18 +- .../components/trade-in/v2/SourcesView.tsx | 145 ++-- .../src/components/trade-in/v2/mappers.ts | 674 ++++++++++++++++++ 6 files changed, 905 insertions(+), 107 deletions(-) diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 03dbf0e6..9004b698 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -23,11 +23,18 @@ import { ObjectSummary } from "@/components/trade-in/v2/ObjectSummary"; import { Footer } from "@/components/trade-in/v2/Footer"; import SectionOverlay from "@/components/trade-in/v2/SectionOverlay"; import { LocationDrawer } from "@/components/trade-in/v2/LocationDrawer"; -import type { ObjectInfo, Report } from "@/components/trade-in/v2/types"; +import type { Analytics, ObjectInfo, Report } from "@/components/trade-in/v2/types"; +import type { + HistoryData, + SourcesData, +} from "@/components/trade-in/v2/mappers"; import { + mapAnalytics, + mapHistory, mapObject, mapReport, mapResultPanel, + mapSources, mapSummary, } from "@/components/trade-in/v2/mappers"; @@ -40,6 +47,9 @@ import { useEstimate, useEstimateHouseAnalytics, useEstimateMutation, + useEstimatePlacementHistory, + useEstimateSellTimeSensitivity, + useSalesVsListings, useStreetDeals, } from "@/lib/trade-in-api"; @@ -388,9 +398,22 @@ export default function TradeInV2Page() { estimate?.rooms ?? null, ); const analytics = useEstimateHouseAnalytics(currentEstimateId); + // Overlay-only sub-hooks (04 ПРОДАЖИ В ДОМЕ / 05 РЫНОК / 06 АНАЛИТИКА). Same + // contract: each resolves independently; a pending/errored one degrades its + // overlay section to an honest empty via the mapper (null input). + const placementHistory = useEstimatePlacementHistory(currentEstimateId); + const salesVsListings = useSalesVsListings( + estimate?.target_address ?? null, + estimate?.area_m2 ?? null, + estimate?.rooms ?? null, + ); + const sellTime = useEstimateSellTimeSensitivity(currentEstimateId); const streetDealsData = streetDeals.data ?? null; const analyticsData = analytics.data ?? null; + const placementHistoryData = placementHistory.data ?? null; + const salesVsListingsData = salesVsListings.data ?? null; + const sellTimeData = sellTime.data ?? null; // ── Derived state flags ─────────────────────────────────────────────────── const restoreActive = urlId !== null && freshResult === null; @@ -425,6 +448,24 @@ export default function TradeInV2Page() { [estimate, analyticsData, streetDealsData], ); + // ── Overlay (section) mapped data. Computed unconditionally: the mappers + // accept nulls and degrade per-section, so an overlay opened before/without + // an estimate shows an honest empty shell — never the design fixtures as if + // they were a real report. ───────────────────────────────────────────── + const historyData = useMemo( + () => + mapHistory(placementHistoryData, streetDealsData, salesVsListingsData), + [placementHistoryData, streetDealsData, salesVsListingsData], + ); + const analyticsViewData = useMemo( + () => mapAnalytics(analyticsData, sellTimeData, estimate), + [analyticsData, sellTimeData, estimate], + ); + const sourcesData = useMemo( + () => mapSources(estimate, streetDealsData), + [estimate, streetDealsData], + ); + // Prefill for restore-by-id / re-estimate. Keyed remount applies it when the // estimate arrives async (useState reads initialValues only on mount). const initialValues = useMemo | undefined>( @@ -580,6 +621,9 @@ export default function TradeInV2Page() { active={nav} onClose={() => setNav(0)} onNavigate={setNav} + history={historyData} + analytics={analyticsViewData} + sources={sourcesData} /> )} setDrawerOpen(false)} /> diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx index 50408d28..d1001fb3 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx @@ -1,21 +1,24 @@ "use client"; // Overlay 06 "АНАЛИТИКА ДОМА" — markup port from the МЕРА Оценка pixel design. -// Data comes from fixtures (no API); the price-history dots navigate to the +// Accepts one composite `data` prop (default = the design fixture); page.tsx +// feeds mapAnalytics(...) output. The price-history dots navigate to the // "Аналогичные объявления" overlay via onNavigate?.(2). -import { useState } from "react"; +import { useMemo, useState } from "react"; import { tokens } from "./tokens"; -import { analytics } from "./fixtures"; -import type { SellTimeTier } from "./types"; +import { analytics as ANALYTICS_FIXTURE } from "./fixtures"; +import type { Analytics, SellTimeTier } from "./types"; interface AnalyticsViewProps { + data?: Analytics; onNavigate?: (i: number) => void; } // ---- price-history geometry ----------------------------------------------- // Parse the SVG polyline point strings into coordinates so the markers and -// the transparent hover targets can be derived from the same source. +// the transparent hover targets can be derived from the same source. Empty +// series (graceful-degradation case) parse to [] — no dots, no hover targets. interface Pt { x: number; @@ -26,20 +29,13 @@ function parsePoints(s: string): Pt[] { return s .trim() .split(/\s+/) + .filter((pair) => pair.length > 0) .map((pair) => { const [x, y] = pair.split(",").map(Number); return { x, y }; }); } -const avitoPts = parsePoints(analytics.priceHistory.avito); -const yandexPts = parsePoints(analytics.priceHistory.yandex); -// Яндекс only draws dots where its series diverges from Avito (последние 3 года). -const yandexDots = yandexPts.filter( - (p, i) => !avitoPts[i] || p.x !== avitoPts[i].x || p.y !== avitoPts[i].y, -); -const hoverPts: Pt[] = [...avitoPts, ...yandexDots]; - // X positions of the year axis labels (chart scaffolding, not in fixtures). const histYearX = [50, 250, 460, 670, 860]; @@ -82,9 +78,35 @@ const cardStyle = { padding: "16px 18px", } as const; -export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) { +export default function AnalyticsView({ + data = ANALYTICS_FIXTURE, + onNavigate, +}: AnalyticsViewProps) { const [hoverHist, setHoverHist] = useState(false); + // Price-history series + hover targets, derived from the data prop. Яндекс + // only draws dots where its series diverges from Avito (последние 3 года). + const avitoPts = useMemo( + () => parsePoints(data.priceHistory.avito), + [data.priceHistory.avito], + ); + const yandexPts = useMemo( + () => parsePoints(data.priceHistory.yandex), + [data.priceHistory.yandex], + ); + const yandexDots = useMemo( + () => + yandexPts.filter( + (p, i) => + !avitoPts[i] || p.x !== avitoPts[i].x || p.y !== avitoPts[i].y, + ), + [yandexPts, avitoPts], + ); + const hoverPts = useMemo( + () => [...avitoPts, ...yandexDots], + [avitoPts, yandexDots], + ); + return (
{/* header */} @@ -99,7 +121,7 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) { Аналитика дома
- {analytics.header.ads} · {analytics.header.radius} + {data.header.ads} · {data.header.radius}
@@ -111,7 +133,7 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) { gap: 16, }} > - {analytics.kpis.map((kpi, i) => ( + {data.kpis.map((kpi, i) => (
- {analytics.sellTimeNote} + {data.sellTimeNote}
- {analytics.sellTime.map((tier, i) => { + {data.sellTime.map((tier, i) => { const v = tierStyles[tier.variant]; return (
- {analytics.priceHistory.note} + {data.priceHistory.note}
@@ -278,20 +300,20 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) { - {analytics.priceHistory.years.map((yr, i) => ( + {data.priceHistory.years.map((yr, i) => ( {yr} ))}
Зависимость цены от срока экспозиции ·{" "} - {analytics.scatterDetail.note} + {data.scatterDetail.note}
@@ -475,24 +497,24 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) { opacity={0.45} /> - {analytics.scatterDetail.deals.map((p, i) => ( + {data.scatterDetail.deals.map((p, i) => ( ))} - {analytics.scatterDetail.analogs.map((p, i) => ( + {data.scatterDetail.analogs.map((p, i) => ( ))} - {analytics.scatterDetail.yTicks.map((t) => ( + {data.scatterDetail.yTicks.map((t) => ( {t.label} @@ -517,7 +539,7 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) { fill={tokens.muted2} textAnchor="middle" > - {analytics.scatterDetail.xTicks.map((t) => ( + {data.scatterDetail.xTicks.map((t) => ( {t.label} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/HistoryView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/HistoryView.tsx index ca0fbeed..36aa8ddb 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/HistoryView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/HistoryView.tsx @@ -2,16 +2,25 @@ // Faithful markup port of the design's showHistory block (МЕРА Оценка.dc.html, // lines 457-489): "История продаж в этом доме" card (house sales) + // "ДКП-сделки на улице" card (3 KPI + dealine table + footer count). -// Static markup — data comes from fixtures, no fetch / no local state. +// Data arrives via the composite `data` prop (mapHistory); the design fixtures +// are the default for unwired/storybook usage. No fetch / no local state. import { tokens } from "./tokens"; import { history, dkpRows } from "./fixtures"; +import type { HistoryData } from "./mappers"; // Shared grid templates (kept verbatim from the design column tracks). const houseGrid = "2fr 1.4fr 1fr 1fr"; const dkpGrid = "2.2fr 1fr 1.3fr 1fr .9fr 2fr"; -export default function HistoryView() { +// Default presentation data (unwired usage): the existing design fixtures. +const HISTORY_FIXTURE: HistoryData = { ...history, dkpRows }; + +interface HistoryViewProps { + data?: HistoryData; +} + +export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps) { return (
{/* ── История продаж в этом доме ───────────────────────────── */} @@ -49,7 +58,7 @@ export default function HistoryView() { color: tokens.muted2, }} > - {history.houseSales.length} ЛОТА + {data.houseSales.length} ЛОТА
@@ -71,7 +80,7 @@ export default function HistoryView() { ЭКСПОЗИЦИЯ
- {history.houseSales.map((row, i) => ( + {data.houseSales.map((row, i) => (
- {history.dkpHeader.title} + {data.dkpHeader.title}
Период:{" "} - {history.dkpHeader.period} + {data.dkpHeader.period}
Источник: Росреестр + объявления
@@ -166,7 +175,7 @@ export default function HistoryView() { marginTop: "8px", }} > - {history.dkpHeader.note} + {data.dkpHeader.note}
@@ -203,7 +212,7 @@ export default function HistoryView() { marginTop: "4px", }} > - {history.dkpKpi.count} + {data.dkpKpi.count}
- {history.dkpKpi.median}{" "} + {data.dkpKpi.median}{" "} ₽/м² @@ -255,7 +264,7 @@ export default function HistoryView() { marginTop: "4px", }} > - {history.dkpKpi.range}{" "} + {data.dkpKpi.range}{" "} млн ₽ @@ -284,7 +293,7 @@ export default function HistoryView() { ОБЪЯВЛЕНИЕ ДО СДЕЛКИ
- {dkpRows.map((r, i) => ( + {data.dkpRows.map((r, i) => (
- - Екатеринбург, Академика Ландау - + {r.addr ?? "—"} {r.area} @@ -312,7 +319,7 @@ export default function HistoryView() { - 01.01.26 + {r.date ?? "—"} - Яндекс + {r.source ?? "—"} {r.ask} {r.delta} @@ -351,7 +358,7 @@ export default function HistoryView() { background: tokens.surfaceTint, }} > - Показано {dkpRows.length} из {history.dkpKpi.count} сделок + Показано {data.dkpRows.length} из {data.dkpKpi.count} сделок
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx index 7f7e465f..0fee8458 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx @@ -12,17 +12,27 @@ import HistoryView from "./HistoryView"; import SourcesView from "./SourcesView"; import AnalyticsView from "./AnalyticsView"; import { CacheView } from "./CacheView"; +import type { HistoryData, SourcesData } from "./mappers"; +import type { Analytics } from "./types"; interface SectionOverlayProps { active: number; onClose: () => void; onNavigate: (i: number) => void; + // Mapped overlay data, fed by page.tsx (mapHistory / mapSources / mapAnalytics). + // Optional so unwired/storybook usage falls back to each view's fixture default. + history?: HistoryData; + sources?: SourcesData; + analytics?: Analytics; } export default function SectionOverlay({ active, onClose, onNavigate, + history, + sources, + analytics, }: SectionOverlayProps) { return (
- {active === 1 && } - {active === 2 && } - {active === 3 && } + {active === 1 && } + {active === 2 && } + {active === 3 && ( + + )} {active === 4 && }
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx index 9ea730a7..00d075ee 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx @@ -1,14 +1,16 @@ import type { CSSProperties } from "react"; +import { safeUrl } from "@/lib/safeUrl"; import { tokens } from "./tokens"; import { adRows, dealRows, marketAds, marketDeals } from "./fixtures"; -import type { AdRow, DealRow } from "./types"; +import type { SourcesData } from "./mappers"; // Overlay 05 — РЫНОК · АНАЛОГИ И СДЕЛКИ. // Faithful markup port from "МЕРА Оценка.dc.html" (lines 492-537). // Card A: объявления в продаже (3 KPI + filter chips + adRows table). // Card B: фактические сделки (3 KPI + dealRows table). -// Static markup: data from fixtures, no fetching, no interactive state. +// Markup is data-driven via the `data` prop; the design fixtures stay as the +// DEFAULT so unwired/storybook usage still renders pixel-identically. // One-off tints not present in the v2 palette (kept literal, faithful to design). const KPI_BG = "rgba(238,244,250,.5)"; @@ -121,6 +123,18 @@ const monoMuted2: CSSProperties = { color: tokens.muted2, }; +// Shared "open original ↗" cell style (anchor + fixture-fallback span render +// identically; textDecoration:none keeps the anchor visually a button). +const linkBtn: CSSProperties = { + fontSize: "9.5px", + color: tokens.accent, + border: `1px solid ${tokens.line2}`, + borderRadius: 4, + padding: "3px 8px", + textAlign: "center", + textDecoration: "none", +}; + function KpiCell({ label, value, @@ -160,7 +174,20 @@ function KpiCell({ ); } -export default function SourcesView() { +// Default presentation data (unwired/storybook usage): the existing design +// fixtures. AdRow[]/DealRow[] satisfy AdRowData[]/DealRowData[] (extras optional). +const SOURCES_FIXTURE: SourcesData = { + adRows, + dealRows, + marketAds, + marketDeals, +}; + +interface SourcesViewProps { + data?: SourcesData; +} + +export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps) { return (