diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 4bd6fd4e..03dbf0e6 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -1,7 +1,18 @@ "use client"; -import { useState } from "react"; -import type { CSSProperties } from "react"; +// /trade-in/v2 — "МЕРА Оценка" HUD dashboard, wired to the real trade-in API. +// This page OWNS the data: it runs the estimate mutation + restore-by-id query, +// spawns the dashboard sub-hooks (street deals, house analytics) and feeds the +// pixel-perfect HUD components via the pure mappers (mapReport / mapObject / +// mapResultPanel / mapSummary). The artboard frame / brackets / 474-1fr-250 grid +// are unchanged — only the result/summary areas swap between honest empty / grey +// skeleton / inline-error / real-data states. Sub-hooks resolve independently: +// a pending or errored street-deals / analytics call degrades its section to a +// fallback inside the mapper instead of blanking the page. + +import { useMemo, useState } from "react"; +import type { CSSProperties, ReactNode } from "react"; +import { useRouter } from "next/navigation"; import { tokens } from "@/components/trade-in/v2/tokens"; import TopNav from "@/components/trade-in/v2/TopNav"; @@ -12,6 +23,25 @@ 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 { + mapObject, + mapReport, + mapResultPanel, + mapSummary, +} from "@/components/trade-in/v2/mappers"; + +import type { + AggregatedEstimate, + TradeInEstimateInput, +} from "@/types/trade-in"; +import { HTTPError } from "@/lib/api"; +import { + useEstimate, + useEstimateHouseAnalytics, + useEstimateMutation, + useStreetDeals, +} from "@/lib/trade-in-api"; // OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative, // non-interactive overlay drawn over the artboard gradient. The frame has @@ -64,9 +94,408 @@ const brackets: { key: string; style: CSSProperties }[] = [ }, ]; +// Honest neutral fallbacks for the meta blocks (HeroBar / Footer) before there +// is an estimate. Dashes — never the design fixtures (which would read as a fake +// real report). +const EMPTY_REPORT: Report = { id: "—", date: "—", validUntil: "—" }; +const EMPTY_OBJECT: ObjectInfo = { + address: "—", + city: "", + area: "—", + rooms: "—", + floor: "—", + totalFloors: "—", + year: "—", + houseType: "—", + repair: "—", + balcony: false, + locationCoef: "—", + streetView: "", + compass: "", +}; + +// Skeleton pulse (opacity only — NO shimmer sweep, per .claude/rules/ui-*) + +// retry-button hover. +const HELPER_STYLES = ` +@keyframes v2-skel-pulse{0%{opacity:.45}50%{opacity:.78}100%{opacity:.45}} +.v2-skel{animation:v2-skel-pulse 1.5s ease-in-out infinite} +.v2-retry-btn{transition:all .15s;cursor:pointer} +.v2-retry-btn:hover{border-color:${tokens.accent};color:${tokens.accent};background:rgba(46,139,255,.07)} +`; + +const retryBtnStyle: CSSProperties = { + padding: "8px 16px", + background: "transparent", + border: `1px solid ${tokens.line}`, + borderRadius: 6, + fontFamily: tokens.font.sans, + fontSize: 11, + fontWeight: 600, + letterSpacing: 1.5, + color: tokens.muted, +}; + +// ── Result/summary area state panels (kept inside the page — these are layout +// states, not reusable design components, so they do not belong in v2/). ─── + +function SkeletonBlock({ + height, + flex, +}: { + height?: number; + flex?: number; +}) { + return ( +
+ ); +} + +function ResultSkeleton() { + return ( +
+ +
+ + + +
+ + +
+ ); +} + +function SummarySkeleton() { + return ( +
+ ); +} + +function PlaceholderPanel({ + title, + body, + children, +}: { + title: string; + body: string; + children?: ReactNode; +}) { + return ( +
+
+ {title} +
+
+ {body} +
+ {children} +
+ ); +} + +function EmptyResultPanel() { + return ( + + ); +} + +function InsufficientPanel() { + return ( + + ); +} + +function RestoreErrorPanel({ + notFound, + onRetry, + onNew, +}: { + notFound: boolean; + onRetry: () => void; + onNew: () => void; +}) { + return ( + +
+ {!notFound && ( + + )} + +
+
+ ); +} + +function SummaryPlaceholder() { + return ( +
+
+ СВОДКА ОБЪЕКТА +
+
+ Появится после оценки квартиры. +
+
+ ); +} + +// Restore-by-id: read ?id= from the URL with an SSR guard. Mirrors v1 (app/ +// page.tsx) deliberately — next/navigation useSearchParams would force a +// Suspense boundary and break `next build`. +function readUrlId(): string | null { + if (typeof window === "undefined") return null; + return new URLSearchParams(window.location.search).get("id"); +} + export default function TradeInV2Page() { + const router = useRouter(); + const [nav, setNav] = useState(0); const [drawerOpen, setDrawerOpen] = useState(false); + // Locally-held just-computed estimate (so we render instantly after submit + // without a round-trip through the restore query). + const [freshResult, setFreshResult] = useState( + null, + ); + + const urlId = readUrlId(); + + const mutation = useEstimateMutation(); + // Skip the restore fetch once we already hold a fresh result. + const restored = useEstimate(freshResult === null ? urlId : null); + + const estimate: AggregatedEstimate | null = freshResult ?? restored.data ?? null; + const currentEstimateId = freshResult?.estimate_id ?? urlId; + + // Dashboard sub-hooks — each resolves independently; failure degrades its + // section via the mappers (null input) rather than blanking the page. + const streetDeals = useStreetDeals( + estimate?.target_address ?? null, + estimate?.area_m2 ?? null, + estimate?.rooms ?? null, + ); + const analytics = useEstimateHouseAnalytics(currentEstimateId); + + const streetDealsData = streetDeals.data ?? null; + const analyticsData = analytics.data ?? null; + + // ── Derived state flags ─────────────────────────────────────────────────── + const restoreActive = urlId !== null && freshResult === null; + const restoreLoading = restoreActive && restored.isPending; + const restoreError = restoreActive && restored.isError; + const restoreNotFound = + restoreError && + restored.error instanceof HTTPError && + restored.error.status === 404; + + const loading = mutation.isPending || restoreLoading; + const insufficient = + estimate != null && (estimate.insufficient_data || estimate.n_analogs === 0); + const apiError = mutation.error?.message ?? null; + + // ── Mapped presentation data (memoised so nav/drawer toggles don't recompute + // geometry). ────────────────────────────────────────────────────────── + const report = useMemo( + () => (estimate ? mapReport(estimate) : EMPTY_REPORT), + [estimate], + ); + const objectInfo = useMemo( + () => (estimate ? mapObject(estimate) : EMPTY_OBJECT), + [estimate], + ); + const resultPanelData = useMemo( + () => (estimate ? mapResultPanel(estimate, streetDealsData) : null), + [estimate, streetDealsData], + ); + const summaryData = useMemo( + () => (estimate ? mapSummary(estimate, analyticsData, streetDealsData) : null), + [estimate, analyticsData, 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>( + () => + estimate + ? { + address: estimate.target_address ?? undefined, + area_m2: estimate.area_m2 ?? undefined, + rooms: estimate.rooms ?? undefined, + floor: estimate.floor, + total_floors: estimate.total_floors, + year_built: estimate.year_built ?? undefined, + house_type: estimate.house_type ?? undefined, + repair_state: estimate.repair_state ?? undefined, + has_balcony: estimate.has_balcony ?? undefined, + } + : undefined, + [estimate], + ); + + function handleSubmit(input: TradeInEstimateInput) { + mutation.mutate(input, { + onSuccess: (est) => { + setFreshResult(est); + // basePath auto-prefixes → /trade-in/v2?id=... + router.replace(`/v2?id=${est.estimate_id}`, { scroll: false }); + }, + }); + } + + function handleRetry() { + void restored.refetch(); + } + + function handleNew() { + setFreshResult(null); + router.replace("/v2", { scroll: false }); + } + + let middleContent: ReactNode; + if (loading) { + middleContent = ; + } else if (restoreError) { + middleContent = ( + + ); + } else if (estimate && !insufficient && resultPanelData) { + middleContent = ; + } else if (estimate && insufficient) { + middleContent = ; + } else { + middleContent = ; + } + + let rightContent: ReactNode; + if (loading) { + rightContent = ; + } else if (estimate && !insufficient && summaryData) { + rightContent = ( + + ); + } else { + rightContent = ; + } return (
+ + {/* OUTER HUD FRAME */}
- setDrawerOpen(true)} /> + setDrawerOpen(true)} + />
- - - + + {middleContent} + {rightContent}
-
+
{nav !== 0 && ( diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx index e4e05182..cfb9c908 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx @@ -5,8 +5,13 @@ import { tokens } from "./tokens"; import { report, version } from "./fixtures"; +import type { Report } from "./types"; -export function Footer() { +interface FooterProps { + data?: Report; +} + +export function Footer({ data = report }: FooterProps) { return (
- ОТЧЁТ {report.id} + ОТЧЁТ {data.id} - ДАТА {report.date} + ДАТА {data.date} ДЕЙСТВИТЕЛЕН ДО{" "} - {report.validUntil} + {data.validUntil}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx index 82200b3c..78cc8032 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx @@ -4,12 +4,20 @@ import Image from "next/image"; import { tokens } from "./tokens"; import { object, report } from "./fixtures"; +import type { HeroBarData } from "./mappers"; + +// Default presentation data (unwired usage): the existing design fixtures. +const HERO_FIXTURE: HeroBarData = { report, object }; interface HeroBarProps { + data?: HeroBarData; onOpenInfo: () => void; } -export default function HeroBar({ onOpenInfo }: HeroBarProps) { +export default function HeroBar({ + data = HERO_FIXTURE, + onOpenInfo, +}: HeroBarProps) { return (
- {report.id} + {data.report.id}
- {report.date} + {data.report.date}
- {report.validUntil} + {data.report.validUntil}
@@ -252,12 +260,12 @@ export default function HeroBar({ onOpenInfo }: HeroBarProps) { АДРЕС
- {object.address} + {data.object.address}
- {object.city} + {data.object.city}
- {object.streetView} + {data.object.streetView}
- {object.locationCoef} + {data.object.locationCoef} - {object.compass} + {data.object.compass}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx index 7c58c339..1cf4f4b2 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx @@ -8,12 +8,20 @@ import { tokens } from "./tokens"; import { object, summary } from "./fixtures"; +import type { ObjectSummaryData } from "./mappers"; + +// Default presentation data (unwired usage): the existing design fixtures. +const OBJECT_SUMMARY_FIXTURE: ObjectSummaryData = { object, summary }; interface ObjectSummaryProps { + data?: ObjectSummaryData; onNavigate: (i: number) => void; } -export function ObjectSummary({ onNavigate }: ObjectSummaryProps) { +export function ObjectSummary({ + data = OBJECT_SUMMARY_FIXTURE, + onNavigate, +}: ObjectSummaryProps) { return (
-
{object.address}
+
{data.object.address}
- {object.city} + {data.object.city}
@@ -148,7 +156,7 @@ export function ObjectSummary({ onNavigate }: ObjectSummaryProps) { {/* Per-section totals (clickable) */}
- {summary.rows.map((row, i) => ( + {data.summary.rows.map((row, i) => (
Источников - {summary.quality.sources} + {data.summary.quality.sources}
Достоверность - {summary.quality.confidence} + {data.summary.quality.confidence}
- {summary.quality.cv} + {data.summary.quality.cv}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx index 04f068f8..a37bf404 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx @@ -1,15 +1,31 @@ "use client"; // 01 ПАРАМЕТРЫ КВАРТИРЫ — left card of the /trade-in/v2 "МЕРА Оценка" port. -// Faithful markup port (МЕРА Оценка.dc.html lines 146-240). Markup-first: all -// values come from fixtures (no API). Dropdowns / balcony toggle drive local UI -// state only. Hover/active + @keyframes live in a pp-prefixed local