All checks were successful
CI Trade-In / changes (pull_request) Successful in 16s
CI / changes (pull_request) Successful in 16s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
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 1m13s
- SectionOverlay: убран aria-modal (честный non-modal fullscreen-view); focus-trap теперь охватывает живой верхний nav + оверлей (query по артборду, фильтр [inert]), Tab из nav возвращается в оверлей, не в спрятанный main/footer. - Контраст 6 muted/disabled токенов затемнён до >=4.6:1 vs worst-case #dce6f1 (hue сохранён, lightness-only): muted2/3/4, hint, disabledValue/Label. - accent -> accentDeep как мелкий текст: номер секции оверлея (15px), счётчик "Мои отчёты" (10px). - Семантика заголовков: sr-only h1 «Мера — оценка квартиры» + h2 (оверлей, ResultPanel, ObjectSummary, placeholder-панели). - HeroBar «КОЭФ. ЛОКАЦИИ»: role=button + tabIndex + Enter/Space + aria-label. - Фокус после успешной оценки переносится на результат-регион (role=region, tabIndex=-1, aria-labelledby); live-region «Оценка готова» сохранён. - Инпут focus-ring усилен (2px solid accentDeep). - combobox адреса: aria-live анонс количества подсказок; aria-required на адрес + площадь; декоративные inline-SVG помечены aria-hidden.
843 lines
27 KiB
TypeScript
843 lines
27 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 { 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,
|
||
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,
|
||
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,
|
||
);
|
||
|
||
const urlId = 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); recomputed on resize.
|
||
const [artboardScale, setArtboardScale] = useState(1);
|
||
useEffect(() => {
|
||
const compute = () =>
|
||
setArtboardScale(
|
||
Math.max(
|
||
0.88,
|
||
Math.min(1, window.innerWidth / 1536, window.innerHeight / 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);
|
||
|
||
const estimate: AggregatedEstimate | null =
|
||
freshResult ?? restored.data ?? null;
|
||
const currentEstimateId = freshResult?.estimate_id ?? urlId;
|
||
|
||
// 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} — no display name / email, so
|
||
// name falls back to username, org to brand ?? role, email to "" (renders
|
||
// blank, never invented). undefined while loading → TopNav «Гость» fallback.
|
||
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);
|
||
// 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 placementHistoryData = placementHistory.data ?? null;
|
||
const salesVsListingsData = salesVsListings.data ?? null;
|
||
const sellTimeData = sellTime.data ?? null;
|
||
|
||
// ── Derived state flags ───────────────────────────────────────────────────
|
||
const restoreActive = urlId !== null && freshResult === null;
|
||
const restoreLoading = restoreActive && restored.isPending;
|
||
const restoreError = restoreActive && restored.isError;
|
||
const restoreNotFound =
|
||
restoreError &&
|
||
restored.error instanceof HTTPError &&
|
||
restored.error.status === 404;
|
||
|
||
const loading = mutation.isPending || restoreLoading;
|
||
const insufficient =
|
||
estimate != null &&
|
||
(estimate.insufficient_data || estimate.n_analogs === 0);
|
||
const apiError = mutation.error?.message ?? null;
|
||
// 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) : EMPTY_OBJECT),
|
||
[estimate],
|
||
);
|
||
const resultPanelData = useMemo(
|
||
() => (estimate ? mapResultPanel(estimate, streetDealsData) : null),
|
||
[estimate, streetDealsData],
|
||
);
|
||
const summaryData = useMemo(
|
||
() =>
|
||
estimate ? mapSummary(estimate, analyticsData, streetDealsData) : null,
|
||
[estimate, analyticsData, streetDealsData],
|
||
);
|
||
|
||
// 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 "Андрей Петров / Брусника").
|
||
const topNavUser = useMemo(() => {
|
||
const scope = me.data;
|
||
if (!scope) return undefined;
|
||
return {
|
||
name: scope.username,
|
||
org: scope.brand ?? scope.role,
|
||
email: "",
|
||
initials: scope.username.slice(0, 2).toUpperCase(),
|
||
};
|
||
}, [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 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);
|
||
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 = "Оценка готова";
|
||
}
|
||
|
||
return (
|
||
<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" }}
|
||
>
|
||
<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}
|
||
error={apiError}
|
||
initialValues={initialValues}
|
||
markers={markers}
|
||
/>
|
||
{middleContent}
|
||
{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}
|
||
/>
|
||
)}
|
||
<LocationDrawer
|
||
open={drawerOpen}
|
||
onClose={() => setDrawerOpen(false)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|