gendesign/tradein-mvp/frontend/src/app/v2/page.tsx
bot-backend 30e158ad2a
All checks were successful
CI / changes (pull_request) Successful in 8s
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
fix(tradein/v2): UI/UX-аудит P1 quick-wins (#2062)
Честная полировка живого /trade-in/v2 без правок архитектуры (FE-only).

- Провенанс из data, без хардкодов: footer «ПОСТРОЕНО ПО N АНАЛОГАМ И M
  СДЕЛКАМ» из meta.builtOn; единый счётчик источников (activeSourceCount =
  залитые плитки) в шапке 02, СВОДКЕ и оверлее; убраны «6/10», «2/7»,
  «Avito·10», «из 2 источников».
- Единый ru-форматтер: запятая везде (CV/площадь/%), NBSP число+единица,
  один en-dash и одна точность для денежных диапазонов.
- CTA «ОЦЕНИТЬ КВАРТИРУ» → solid brand-blue + белый текст; :focus-visible
  HUD-glow на инпутах и кнопке.
- Лендмарки <nav>/<main>/<footer> (display:contents, 0 визуальных изменений);
  scale-fit артборда — на 1366×768 / 1440×900 ужимается, не режется.
- Мини-карта: честный зум (min=1, без пустых краёв при отдалении), убран
  мёртвый crosshair-двойник, расклеены налезающие метки цен.
- CRM-дропдаун явно задизейблен («скоро», без ▼); убран «ШАГ 1/1».
- Карточка 02 → «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ · с учётом торга» + строка-примирение
  с медианой ДКП; КОЭФ.ЛОКАЦИИ → muted-пилюля «скоро».
- Бейдж СВОДКИ «В РАСЧЁТЕ» → «АКТУАЛЬНО»; скрыты целиком-прочерк колонки
  (ИСТОЧНИКОВ в 07, ОБЪЯВЛЕНИЕ ДО СДЕЛКИ в 04) data-driven.
2026-06-28 19:33:12 +03:00

733 lines
23 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, 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,
}}
>
<div
style={{
fontSize: 13,
fontWeight: 600,
letterSpacing: 2,
color: tokens.ink,
}}
>
{title}
</div>
<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,
}}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
letterSpacing: 1.5,
color: tokens.ink,
}}
>
СВОДКА ОБЪЕКТА
</div>
<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);
// 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.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;
// ── 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],
);
// ── 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} />;
} 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 />;
}
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>
{/* 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",
}}
>
<nav aria-label="Разделы" style={{ display: "contents" }}>
<TopNav
active={nav}
onNavigate={setNav}
reports={reportsCount ?? 0}
user={topNavUser}
onLogout={logout}
/>
</nav>
<main style={{ display: "contents" }}>
<HeroBar
data={{ report, object: objectInfo }}
estimateId={mounted ? currentEstimateId : null}
onOpenInfo={() => setDrawerOpen(true)}
/>
<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 style={{ display: "contents" }}>
<Footer data={report} />
</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>
);
}