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
- Гистограммы флагман-карточки (ОБЪЯВЛЕНИЯ) и ДКП/сделок биннятся по 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 сохранён.
1067 lines
39 KiB
TypeScript
1067 lines
39 KiB
TypeScript
"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 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>
|
||
);
|
||
}
|