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}