"use client"; // /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 { useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, ReactNode } from "react"; import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { tokens } from "@/components/trade-in/v2/tokens"; import TopNav from "@/components/trade-in/v2/TopNav"; import HeroBar from "@/components/trade-in/v2/HeroBar"; import ParamsPanel from "@/components/trade-in/v2/ParamsPanel"; import ResultPanel from "@/components/trade-in/v2/ResultPanel"; import { ObjectSummary } from "@/components/trade-in/v2/ObjectSummary"; import { LeadForm } from "@/components/trade-in/v2/LeadForm"; 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 { Analytics, ObjectInfo, Report, } from "@/components/trade-in/v2/types"; import type { CacheData, HistoryData, SourcesData, } from "@/components/trade-in/v2/mappers"; import { mapAnalytics, mapCache, mapHistory, mapLocation, mapMarkers, mapObject, mapReport, mapResultPanel, mapSources, mapSummary, } from "@/components/trade-in/v2/mappers"; import type { AggregatedEstimate, TradeInEstimateInput, } from "@/types/trade-in"; import { HTTPError } from "@/lib/api"; import { useEstimate, useEstimateHistory, useEstimateHouseAnalytics, useEstimateMutation, useEstimatePlacementHistory, useEstimateSellTimeSensitivity, useLocationCoef, useSalesVsListings, useStreetDeals, } from "@/lib/trade-in-api"; import { useQuota } from "@/lib/useQuota"; import { useMe } from "@/lib/useMe"; import { logout } from "@/lib/logout"; // OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative, // non-interactive overlay drawn over the artboard gradient. The frame has // chamfered octagonal corners (clip-path polygon) + 4px radius; the brackets // sit just outside it at the artboard corners. const FRAME_INSET = 14; const BRACKET_INSET = 6; const BRACKET_ARM = 22; const BRACKET_W = 1.5; const FRAME_CLIP = "polygon(18px 0,calc(100% - 18px) 0,100% 18px,100% calc(100% - 18px),calc(100% - 18px) 100%,18px 100%,0 calc(100% - 18px),0 18px)"; const brackets: { key: string; style: CSSProperties }[] = [ { key: "tl", style: { top: BRACKET_INSET, left: BRACKET_INSET, borderTop: `${BRACKET_W}px solid ${tokens.bracket2}`, borderLeft: `${BRACKET_W}px solid ${tokens.bracket2}`, }, }, { key: "tr", style: { top: BRACKET_INSET, right: BRACKET_INSET, borderTop: `${BRACKET_W}px solid ${tokens.bracket2}`, borderRight: `${BRACKET_W}px solid ${tokens.bracket2}`, }, }, { key: "bl", style: { bottom: BRACKET_INSET, left: BRACKET_INSET, borderBottom: `${BRACKET_W}px solid ${tokens.bracket2}`, borderLeft: `${BRACKET_W}px solid ${tokens.bracket2}`, }, }, { key: "br", style: { bottom: BRACKET_INSET, right: BRACKET_INSET, borderBottom: `${BRACKET_W}px solid ${tokens.bracket2}`, borderRight: `${BRACKET_W}px solid ${tokens.bracket2}`, }, }, ]; // 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 (
{/* Panel heading — real

(preflight not loaded → reset UA margin). */}

{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 (
{/* Panel heading — real

(preflight not loaded → reset UA margin). */}

СВОДКА ОБЪЕКТА

Появится после оценки квартиры.
); } // 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 queryClient = useQueryClient(); const [nav, setNav] = useState(0); const [drawerOpen, setDrawerOpen] = useState(false); // #2264 C7: after a fresh estimate lands we move focus onto the result region // (a labelled, tabIndex={-1} container in ResultPanel) so keyboard/SR users // don't get dumped back on . `focusedEstimateRef` guards against // re-focusing on unrelated re-renders / sub-hook updates. const resultRegionRef = useRef(null); const focusedEstimateRef = useRef(null); // Locally-held just-computed estimate (so we render instantly after submit // without a round-trip through the restore query). const [freshResult, setFreshResult] = useState( null, ); // #2424 fix — router.replace(`?id=`) is an async client-navigation (RSC // round-trip) that does NOT update window.location.search synchronously, but // the onSelectEstimate handler below also calls setNav(0) in the same tick, // which DOES force an immediate synchronous re-render. Without an override, // that re-render's readUrlId() call reads the stale (pre-navigation) URL — // and since this page has no other reactive subscription to the URL, the // stale value is locked in forever (mirrors how `freshResult` already // overrides the same race for the post-submit path below). Cleared once // `restored.data` actually reflects this id (see effect below). // // Three states, not two: `undefined` means "no override, defer to // readUrlId()"; `null` means "override to no-id" (handleNew's reset); // a string means "override to this id" (a row was just clicked). Using // plain `null` for both "no override" and "force no-id" was tried first and // reintroduced this exact race for handleNew(): forcing pendingRestoreId // from a bad id to `null` would, under `pendingRestoreId ?? readUrlId()`, // fall through to readUrlId() on the very next render — which is still // stale until router.replace("/v2") finishes — so the bad id came right // back. The two intents need distinguishable values. const [pendingRestoreId, setPendingRestoreId] = useState< string | null | undefined >(undefined); const urlId = pendingRestoreId !== undefined ? pendingRestoreId : readUrlId(); // Post-mount flag: readUrlId() is null on SSR but a UUID on the client's first // render, so any structure that depends on the id (e.g. the PDF vs disabled //