"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
// ) would diverge between SSR and hydration on a fresh /v2?id= load.
// Gate id-dependent structure behind `mounted` to keep first render identical.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// Scale-fit: shrink the fixed 1536×1024 artboard to fit smaller viewports
// instead of clipping. Never upscale (capped at 1). #2275: this used to floor
// at 0.88, which on a 390px phone still rendered the artboard ~1350px wide —
// page-wide horizontal scroll + a hero clipped at the left edge + the summary
// rail pushed off-screen. Width is now the only constraint (a scaled artboard
// taller than the viewport just scrolls the page vertically, which is normal
// and not the bug being fixed here).
const [artboardScale, setArtboardScale] = useState(1);
// #2275: below this viewport width the fixed-width artboard can no longer
// read at a usable size even scaled-to-fit, so HeroBar + ObjectSummary (the
// "at a glance" content — report meta, PDF CTA, per-section totals) get a real
// fluid mobile block above the artboard instead of a tiny scaled-down copy.
// The rest of the dashboard (ParamsPanel / ResultPanel / overlays) stays
// inside the scaled artboard — smaller on phones, but no longer overflowing.
const [isMobile, setIsMobile] = useState(false);
// Below this width the fixed 1536px artboard scales down to an unreadable
// ~25% and there is no responsive layout yet. Rather than ship that broken
// scaled-down HUD, render an honest "open on desktop" notice instead (see the
// early return below). Starts false so SSR + first client render match the
// desktop tree; the effect flips it post-hydration → no hydration mismatch.
const [isSmallViewport, setIsSmallViewport] = useState(false);
useEffect(() => {
const compute = () => {
setArtboardScale(Math.min(1, window.innerWidth / 1536));
setIsMobile(window.innerWidth < 768);
setIsSmallViewport(window.innerWidth < 1024);
};
compute();
window.addEventListener("resize", compute);
return () => window.removeEventListener("resize", compute);
}, []);
const mutation = useEstimateMutation();
// Skip the restore fetch once we already hold a fresh result.
const restored = useEstimate(freshResult === null ? urlId : null);
// L3 — once the restore-by-id fetch has confirmed the estimate does not
// exist (404), `currentEstimateId` below drops to null so the sibling
// dashboard hooks (analytics/location-coef/placement-history/sell-time,
// all `enabled: estimate_id !== null`) don't each fire their own doomed
// request against the same dead id. Hoisted above the sub-hooks (was
// computed further down, after they'd already fired on the stale id).
const restoreActive = urlId !== null && freshResult === null;
const restoreError = restoreActive && restored.isError;
const restoreNotFound =
restoreError &&
restored.error instanceof HTTPError &&
restored.error.status === 404;
const estimate: AggregatedEstimate | null =
freshResult ?? restored.data ?? null;
const currentEstimateId = restoreNotFound
? null
: (freshResult?.estimate_id ?? urlId);
// Clear the pendingRestoreId override once BOTH (a) the restore query has
// resolved data for that same id AND (b) window.location.search has
// genuinely caught up to it — only then is readUrlId() alone guaranteed to
// keep returning the right value, so the override is no longer needed.
// The (b) check guards a narrow edge case: if the query cache already held
// a fresh (within staleTime) response for the clicked id — e.g. it was the
// just-submitted/just-restored estimate earlier in the same SPA session —
// `restored.data` can resolve synchronously, before router.replace's async
// navigation has actually updated the URL. Clearing on (a) alone would then
// drop the override a beat too early and briefly re-expose the very race
// this state exists to prevent. If (b) isn't true yet, we simply leave the
// (still-correct) override in place — harmless, since it already matches
// what the URL will become — rather than risk reverting to a stale read.
useEffect(() => {
if (
pendingRestoreId !== undefined &&
pendingRestoreId !== null &&
restored.data?.estimate_id === pendingRestoreId &&
readUrlId() === pendingRestoreId
) {
setPendingRestoreId(undefined);
}
}, [pendingRestoreId, restored.data]);
// Per-user side data, independent of the current estimate. Both fail-soft
// (retry:false): history feeds the «Предыдущие оценки» overlay (04), quota
// feeds the TopNav «Мои отчёты» badge. Refreshed after a successful estimate
// via invalidateQueries in handleSubmit.
const history = useEstimateHistory();
const quota = useQuota();
// Real logged-in identity for the TopNav user menu (replaces the design
// fixture). /me returns {username, role, brand, display_name, org, email}
// (#2046) — known profile fields fall back to username / brand ?? role / ""
// when absent (never invented). undefined while loading → TopNav «Гость».
const me = useMe();
// 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);
// LocationDrawer + HeroBar «КОЭФ. ЛОКАЦИИ» (#2317). Same independent-resolve
// contract: a pending/errored/unavailable response degrades to the mapper's
// honest "—" (mapObject/mapLocation), never a fabricated coefficient.
const locationCoef = useLocationCoef(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 locationCoefData = locationCoef.data ?? null;
const placementHistoryData = placementHistory.data ?? null;
const salesVsListingsData = salesVsListings.data ?? null;
const sellTimeData = sellTime.data ?? null;
// ── Derived state flags ───────────────────────────────────────────────────
const restoreLoading = restoreActive && restored.isPending;
const loading = mutation.isPending || restoreLoading;
const insufficient =
estimate != null &&
(estimate.insufficient_data || estimate.n_analogs === 0);
const apiError = mutation.error?.message ?? null;
// M4: estimate-dependent meta/controls (the «ДЕЙСТВИТЕЛЕН ДО» validity line,
// «КАК РАССЧИТАНО», PDF) only render once there is a real, sufficient estimate.
// Folds in `mounted` so SSR and the first client render agree (false → hidden)
// — no hydration drift, same reason the PDF control is gated behind `mounted`.
const hasEstimate = mounted && estimate != null && !insufficient;
// ── 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, locationCoefData) : EMPTY_OBJECT),
[estimate, locationCoefData],
);
const locationData = useMemo(
() => mapLocation(locationCoefData),
[locationCoefData],
);
const resultPanelData = useMemo(
() => (estimate ? mapResultPanel(estimate, streetDealsData) : null),
[estimate, streetDealsData],
);
const summaryData = useMemo(
() =>
estimate ? mapSummary(estimate, analyticsData, streetDealsData) : null,
[estimate, analyticsData, streetDealsData],
);
// Move focus to the result region once a user-submitted estimate is rendered.
// Gated on `freshResult` (a submit, not a restore-by-id cold load, which must
// not steal focus) and on the result actually being on screen (!loading +
// resultPanelData). Fires once per distinct estimate id.
useEffect(() => {
if (
freshResult &&
!loading &&
hasEstimate &&
resultPanelData &&
focusedEstimateRef.current !== freshResult.estimate_id
) {
focusedEstimateRef.current = freshResult.estimate_id;
resultRegionRef.current?.focus();
}
}, [freshResult, loading, hasEstimate, resultPanelData]);
// ── 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],
);
// «Предыдущие оценки» (04) — KPIs + last-estimates table derived purely from
// the per-user /history list (never /cache-stats, which is global/admin).
const cacheData = useMemo(
() => mapCache(history.data ?? null),
[history.data],
);
// TopNav «Мои отчёты» badge: per-user estimate count. quota.used is the
// authoritative monthly counter; fall back to the loaded history length, then
// to the fixture default (undefined) for unwired/pre-load states.
const reportsCount = quota.data?.used ?? history.data?.length;
// Real user for the TopNav menu. undefined until /me resolves → TopNav shows
// its neutral «Гость» fallback (never the old "Андрей Петров / Брусника").
// #2046: display_name/org/email — реальный профиль, если backend его знает
// (см. app.core.auth._USERNAME_PROFILE); иначе фолбэк на username/brand?role/"".
const topNavUser = useMemo(() => {
const scope = me.data;
if (!scope) return undefined;
const nameParts =
scope.display_name?.trim().split(/\s+/).filter(Boolean) ?? [];
const initials =
nameParts.length >= 2
? (nameParts[0][0] + nameParts[1][0]).toUpperCase()
: nameParts.length === 1
? nameParts[0].slice(0, 2).toUpperCase()
: scope.username.slice(0, 2).toUpperCase();
return {
name: scope.display_name ?? scope.username,
org: scope.org ?? scope.brand ?? scope.role,
email: scope.email ?? "",
initials,
};
}, [me.data]);
// Analog price pins for the 01 map, projected from the real estimate. No
// estimate → mapMarkers(null) → [] → ParamsPanel renders only the subject pin
// (Finding #2: never the static fixture price markers).
const markers = useMemo(() => mapMarkers(estimate), [estimate]);
// 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);
// A fresh submission always supersedes any pending restore-by-id
// override (#2424 fix) — drop it so it can't shadow a later restore.
// `undefined` (not `null`): there is no override to force, just none.
setPendingRestoreId(undefined);
// A new estimate consumed quota and added a history row — refresh both
// so the TopNav badge + «Предыдущие оценки» overlay reflect it.
void queryClient.invalidateQueries({
queryKey: ["trade-in", "history"],
});
void queryClient.invalidateQueries({
queryKey: ["trade-in", "quota"],
});
// 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);
// `null`, not `undefined`: forces urlId to no-id immediately, synchronously
// clearing restoreActive/restoreError even before router.replace below
// finishes and readUrlId() would otherwise still read the stale id.
setPendingRestoreId(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 = ;
}
// Polite SR announcement reflecting the estimate lifecycle. Same precedence as
// middleContent: loading → error → insufficient → ready → silent.
let statusMessage = "";
if (loading) {
statusMessage = "Идёт расчёт оценки…";
} else if (restoreError) {
statusMessage = "Ошибка загрузки отчёта";
} else if (estimate && insufficient) {
statusMessage = "Недостаточно данных для оценки";
} else if (estimate && !insufficient) {
statusMessage = "Оценка готова";
}
// Small-viewport guard: the HUD is a fixed 1536px artboard with no responsive
// layout — under ~1024px it scales to an unreadable ~25%. Show an honest
// desktop-only notice instead of the broken scaled-down copy. Placed after all
// hooks so the early return never changes hook order. Desktop (≥1024) is
// untouched.
if (isSmallViewport) {
return (
Мера оптимизирована для десктопа
Откройте оценку на компьютере или расширьте окно браузера до 1024 px
и шире — на узком экране интерфейс отчёта нечитаем.
);
}
return (
{/* #2275 mobile quick-view — a real fluid, native-size block for the "at a
glance" content (HeroBar's report meta + PDF CTA, ObjectSummary's
per-section totals). Inside the scaled artboard below, these would read
as tiny/illegible text on a phone rather than the honest mobile layout
the design deserves — so under the mobile breakpoint they render here
instead (and are skipped inside the artboard, see `!isMobile` below).
The rest of the dashboard (ParamsPanel / ResultPanel / overlays) has no
equivalent fluid mobile layout yet (follow-up) and stays inside the
scaled artboard: smaller on phones, but — with the 0.88 floor removed
above — never wider than the viewport. */}
{isMobile && (
setDrawerOpen(true)}
hasEstimate={hasEstimate}
/>
{rightContent}
)}
{/* #2264 C5: the page's single visible "title" is the SVG logo in TopNav,
so give the document a real, visually-hidden
(page-has-heading-one
+ a top of the heading order for the overlay/panel s). */}
Мера — оценка квартиры
{/* Polite, visually-hidden status announcer for SR users. */}
{statusMessage}
{/* OUTER HUD FRAME */}
{/* 4 corner brackets */}
{brackets.map((b) => (
))}
{/* CONTENT WRAP */}
{/* TopNav stays interactive while a section overlay is open so the
user can switch sections directly from the overlay. Only the
LocationDrawer (a full modal over the whole HUD) inerts it. The
dashboard body (
/) is still inerted behind ANY
overlay/drawer — preserving the focus + click guard from a11y
audit #2081 for everything except the top tabs. */}
{/* #2275: on mobile the readable HeroBar lives in the fluid
quick-view block above instead of a tiny scaled-down copy. */}
{!isMobile && (
setDrawerOpen(true)}
hasEstimate={hasEstimate}
/>
)}
{middleContent}
{/* #2275: on mobile ObjectSummary is rendered fluid in the
quick-view block above instead of here (tiny scaled copy). */}
{!isMobile && rightContent}
{nav !== 0 && (
setNav(0)}
onNavigate={setNav}
history={historyData}
analytics={analyticsViewData}
sources={sourcesData}
cache={cacheData}
estimate={estimate}
onSelectEstimate={(id) => {
// #2420 — same ?id= restore-by-id shape as handleSubmit/handleNew
// above (line ~649); this file uses replace() consistently for
// every /v2?id= transition, so we follow that convention here
// too rather than introducing the file's only push() call.
//
// #2424 fix: router.replace() is an async client-navigation —
// window.location.search does NOT update synchronously — but
// setNav(0) below DOES force an immediate synchronous
// re-render in the same tick. Without pendingRestoreId,
// readUrlId() on that re-render would read the stale
// (pre-navigation) URL and — since this page has no other
// reactive subscription to the URL — that stale value would
// be locked in forever. setPendingRestoreId synchronously
// hands `urlId` the correct target id, mirroring how
// `freshResult` already overrides this same race for the
// post-submit path (handleSubmit above).
setPendingRestoreId(id);
router.replace(`/v2?id=${id}`, { scroll: false });
setNav(0);
}}
/>
)}
setDrawerOpen(false)}
data={locationData}
/>
{/* Lead form (issue #2395 — v2-native mount of #2377's lead capture,
previously stranded in the dead HeroTransparency/HeroSummary tree).
Lives outside the scaled 1536×1024 artboard transform (in normal page
flow) so it stays legible at any viewport/scale instead of shrinking
with the HUD; only shown once a real, sufficient estimate is on
screen — never on the empty/insufficient-data states. */}
{hasEstimate &&
}
);
}