gendesign/tradein-mvp/frontend/src/app/v2/page.tsx
bot-backend 219cdfdbcc
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m23s
fix(tradein/v2): честность гистограмм и оси scatter + мобильный guard + a11y графика
- Гистограммы флагман-карточки (ОБЪЯВЛЕНИЯ) и ДКП/сделок биннятся по
  outlier-guarded пулу (guardPriceOutliers().clean) — консистентно с числом
  «выброс исключён» и спредом на той же карточке. Раньше bins8 спанил
  [min,max] ВКЛЮЧАЯ выброс 872k, схлопывая реальные аналоги в левые бины.
- Ось детального scatter «СРОК ПРОДАЖИ» → «СРОК НА РЫНКЕ»: для активных лотов
  x = дней на рынке (today−listing_date, нижняя граница), а не измеренный
  срок продажи. Обновлены title/aria-label/подзаголовок карточки.
- <1024px: honest desktop-only notice вместо нечитаемого ~25%-scaled артборда
  (client-side width guard, ранний return после всех хуков; десктоп не тронут).
- WCAG 2.1.1: точки истории цен теперь фокусируемы с клавиатуры
  (role=button / tabIndex / Enter+Space / onFocus-onBlur), мышиный hover сохранён.
2026-07-12 22:38:29 +03:00

1067 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 (
<div
className="v2-skel"
style={{
height,
flex,
minHeight: 0,
background: tokens.surface.w50,
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
}}
/>
);
}
function ResultSkeleton() {
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 14,
minWidth: 0,
height: "100%",
}}
>
<SkeletonBlock height={18} />
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gap: 14,
flex: "0 0 auto",
}}
>
<SkeletonBlock height={150} />
<SkeletonBlock height={150} />
<SkeletonBlock height={150} />
</div>
<SkeletonBlock height={150} />
<SkeletonBlock flex={1} />
</div>
);
}
function SummarySkeleton() {
return (
<div
className="v2-skel"
style={{
height: "100%",
background: tokens.surface.w50,
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
}}
/>
);
}
function PlaceholderPanel({
title,
body,
children,
}: {
title: string;
body: string;
children?: ReactNode;
}) {
return (
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 12,
background: tokens.surface.w50,
backdropFilter: "blur(6px)",
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
padding: "40px 28px",
fontFamily: tokens.font.sans,
}}
>
{/* Panel heading — real <h2> (preflight not loaded → reset UA margin). */}
<h2
style={{
margin: 0,
fontSize: 13,
fontWeight: 600,
letterSpacing: 2,
color: tokens.ink,
}}
>
{title}
</h2>
<div
style={{
fontSize: 12,
color: tokens.muted,
maxWidth: 360,
lineHeight: 1.5,
}}
>
{body}
</div>
{children}
</div>
);
}
function EmptyResultPanel() {
return (
<PlaceholderPanel
title="РЕЗУЛЬТАТ ОЦЕНКИ"
body="Заполните параметры квартиры слева и нажмите «ОЦЕНИТЬ КВАРТИРУ» — здесь появится рыночная оценка, диапазоны цен и источники данных."
/>
);
}
function InsufficientPanel() {
return (
<PlaceholderPanel
title="НЕДОСТАТОЧНО ДАННЫХ"
body="По этому адресу не нашлось достаточного числа аналогов и сделок для надёжной оценки. Уточните адрес или параметры квартиры."
/>
);
}
function RestoreErrorPanel({
notFound,
onRetry,
onNew,
}: {
notFound: boolean;
onRetry: () => void;
onNew: () => void;
}) {
return (
<PlaceholderPanel
title={notFound ? "ОТЧЁТ НЕ НАЙДЕН" : "ОШИБКА ЗАГРУЗКИ"}
body={
notFound
? "Ссылка устарела, отчёт удалён или у вас нет к нему доступа."
: "Не удалось загрузить отчёт. Попробуйте ещё раз."
}
>
<div style={{ display: "flex", gap: 10, marginTop: 4 }}>
{!notFound && (
<button
type="button"
className="v2-retry-btn"
onClick={onRetry}
style={retryBtnStyle}
>
ПОВТОРИТЬ
</button>
)}
<button
type="button"
className="v2-retry-btn"
onClick={onNew}
style={retryBtnStyle}
>
НОВАЯ ОЦЕНКА
</button>
</div>
</PlaceholderPanel>
);
}
function SummaryPlaceholder() {
return (
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 8,
background: tokens.surface.w50,
backdropFilter: "blur(6px)",
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
padding: "28px 18px",
fontFamily: tokens.font.sans,
}}
>
{/* Panel heading — real <h2> (preflight not loaded → reset UA margin). */}
<h2
style={{
margin: 0,
fontSize: 12,
fontWeight: 600,
letterSpacing: 1.5,
color: tokens.ink,
}}
>
СВОДКА ОБЪЕКТА
</h2>
<div style={{ fontSize: 11, color: tokens.muted, lineHeight: 1.5 }}>
Появится после оценки квартиры.
</div>
</div>
);
}
// 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 <body>. `focusedEstimateRef` guards against
// re-focusing on unrelated re-renders / sub-hook updates.
const resultRegionRef = useRef<HTMLDivElement>(null);
const focusedEstimateRef = useRef<string | null>(null);
// Locally-held just-computed estimate (so we render instantly after submit
// without a round-trip through the restore query).
const [freshResult, setFreshResult] = useState<AggregatedEstimate | null>(
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 <a> vs disabled
// <button>) 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<HistoryData>(
() =>
mapHistory(placementHistoryData, streetDealsData, salesVsListingsData),
[placementHistoryData, streetDealsData, salesVsListingsData],
);
const analyticsViewData = useMemo<Analytics>(
() => mapAnalytics(analyticsData, sellTimeData, estimate),
[analyticsData, sellTimeData, estimate],
);
const sourcesData = useMemo<SourcesData>(
() => 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<CacheData>(
() => 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<Partial<TradeInEstimateInput> | 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 = <ResultSkeleton />;
} else if (restoreError) {
middleContent = (
<RestoreErrorPanel
notFound={restoreNotFound}
onRetry={handleRetry}
onNew={handleNew}
/>
);
} else if (estimate && !insufficient && resultPanelData) {
middleContent = (
<ResultPanel
data={resultPanelData}
onNavigate={setNav}
regionRef={resultRegionRef}
/>
);
} else if (estimate && insufficient) {
middleContent = <InsufficientPanel />;
} else {
middleContent = <EmptyResultPanel />;
}
let rightContent: ReactNode;
if (loading) {
rightContent = <SummarySkeleton />;
} else if (estimate && !insufficient && summaryData) {
rightContent = (
<ObjectSummary
data={{ object: objectInfo, summary: summaryData }}
onNavigate={setNav}
/>
);
} else {
rightContent = <SummaryPlaceholder />;
}
// 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 (
<div
style={{
minHeight: "100vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
padding: "40px 24px",
boxSizing: "border-box",
gap: 14,
fontFamily: tokens.font.sans,
color: tokens.ink,
background: tokens.gradientBg,
}}
>
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: 0.3 }}>
Мера оптимизирована для десктопа
</h1>
<p
style={{
fontSize: 14,
lineHeight: 1.6,
margin: 0,
maxWidth: 360,
color: tokens.body,
}}
>
Откройте оценку на компьютере или расширьте окно браузера до 1024&nbsp;px
и шире на узком экране интерфейс отчёта нечитаем.
</p>
</div>
);
}
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
width: "100%",
}}
>
{/* #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 && (
<div
style={{
width: "100%",
maxWidth: 480,
margin: "0 auto",
padding: "14px 14px 20px",
boxSizing: "border-box",
fontFamily: tokens.font.sans,
color: tokens.ink,
}}
>
<HeroBar
compact
data={{ report, object: objectInfo }}
estimateId={mounted ? currentEstimateId : null}
onOpenInfo={() => setDrawerOpen(true)}
hasEstimate={hasEstimate}
/>
<div style={{ marginTop: 14 }}>{rightContent}</div>
</div>
)}
<div
style={{ width: 1536 * artboardScale, height: 1024 * artboardScale }}
>
<div
style={{
position: "relative",
width: 1536,
height: 1024,
overflow: "hidden",
background: tokens.gradientBg,
fontFamily: tokens.font.sans,
color: tokens.ink,
transform: `scale(${artboardScale})`,
transformOrigin: "top left",
}}
>
<style>{HELPER_STYLES}</style>
{/* #2264 C5: the page's single visible "title" is the SVG logo in TopNav,
so give the document a real, visually-hidden <h1> (page-has-heading-one
+ a top of the heading order for the overlay/panel <h2>s). */}
<h1
style={{
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0 0 0 0)",
whiteSpace: "nowrap",
border: 0,
}}
>
Мера оценка квартиры
</h1>
{/* Polite, visually-hidden status announcer for SR users. */}
<div
aria-live="polite"
role="status"
style={{
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0 0 0 0)",
whiteSpace: "nowrap",
border: 0,
}}
>
{statusMessage}
</div>
{/* OUTER HUD FRAME */}
<div
style={{
position: "absolute",
inset: FRAME_INSET,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
clipPath: FRAME_CLIP,
pointerEvents: "none",
}}
/>
{/* 4 corner brackets */}
{brackets.map((b) => (
<div
key={b.key}
style={{
position: "absolute",
width: BRACKET_ARM,
height: BRACKET_ARM,
pointerEvents: "none",
...b.style,
}}
/>
))}
{/* CONTENT WRAP */}
<div
style={{
position: "absolute",
inset: 14,
padding: "16px 28px",
display: "flex",
flexDirection: "column",
}}
>
{/* 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 (<main>/<footer>) is still inerted behind ANY
overlay/drawer — preserving the focus + click guard from a11y
audit #2081 for everything except the top tabs. */}
<nav
aria-label="Разделы"
inert={drawerOpen ? true : undefined}
style={{ display: "contents" }}
>
<TopNav
active={nav}
onNavigate={setNav}
reports={reportsCount ?? 0}
user={topNavUser}
onLogout={logout}
/>
</nav>
<main
inert={nav !== 0 || drawerOpen ? true : undefined}
style={{ display: "contents" }}
>
{/* #2275: on mobile the readable HeroBar lives in the fluid
quick-view block above instead of a tiny scaled-down copy. */}
{!isMobile && (
<HeroBar
data={{ report, object: objectInfo }}
estimateId={mounted ? currentEstimateId : null}
onOpenInfo={() => setDrawerOpen(true)}
hasEstimate={hasEstimate}
/>
)}
<div
style={{
display: "grid",
gridTemplateColumns: "474px 1fr 250px",
gap: 18,
flex: 1,
minHeight: 0,
}}
>
<ParamsPanel
key={estimate?.estimate_id ?? "new"}
onSubmit={handleSubmit}
isPending={mutation.isPending}
hasEstimate={hasEstimate}
error={apiError}
initialValues={initialValues}
markers={markers}
/>
{middleContent}
{/* #2275: on mobile ObjectSummary is rendered fluid in the
quick-view block above instead of here (tiny scaled copy). */}
{!isMobile && rightContent}
</div>
</main>
<footer
inert={nav !== 0 || drawerOpen ? true : undefined}
style={{ display: "contents" }}
>
<Footer data={report} hasEstimate={hasEstimate} />
</footer>
</div>
{nav !== 0 && (
<SectionOverlay
active={nav}
onClose={() => 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);
}}
/>
)}
<LocationDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
data={locationData}
/>
</div>
</div>
{/* 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 && <LeadForm estimateId={currentEstimateId} />}
</div>
);
}