From f5a7f9b4377a76e98439c6d18a7cc47dfea68105 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 29 Jun 2026 01:01:30 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/v2):=20UI/UX=20audit=20round=202=20?= =?UTF-8?q?=E2=80=94=20AA=20contrast,=20honesty,=20a11y=20(#2081)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1 (tokens): darken muted scale to WCAG AA (>=4.5:1 vs worst-case canvas #e6eef7); reclassify primary labels muted->body2 across components. H1 (mappers): planning chip derived from rooms actually present in the analog set («Все планировки» when heterogeneous) — no number shifted. H2 (ResultPanel): make «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ» the headline (accent border/tint/glow + larger value); demote ДКП·Росреестр to a muted СПРАВОЧНО reference tile. All three values + mapResultPanel wiring intact. H3 (mappers): parseAddress keeps the house number for prefix-less addresses («Белинского, 86»); prefixed path unchanged. M4 (page/HeroBar/Footer): gate dead controls (КАК РАССЧИТАНО / PDF / ДЕЙСТВИТЕЛЕН ДО / footer validity) on hasEstimate (mounted && estimate && !insufficient). M5 (LocationDrawer): real dialog — role=dialog/aria-modal/aria-labelledby + focus-trap + )} - + {hasEstimate && ( + + )} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/HistoryView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/HistoryView.tsx index cd0faa0f..2c590e3c 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/HistoryView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/HistoryView.tsx @@ -69,52 +69,66 @@ export default function HistoryView({ -
- ЛОТ - ЦЕНА НАЧ. → ПОСЛ. - ДАТА - ЭКСПОЗИЦИЯ -
- - {data.houseSales.map((row, i) => ( +
- {row.lot} - - {row.priceFrom} →{" "} - {row.priceTo} - - - {row.date} - - - {row.exposure} - + ЛОТ + ЦЕНА НАЧ. → ПОСЛ. + ДАТА + ЭКСПОЗИЦИЯ
- ))} + + {data.houseSales.map((row, i) => ( +
+ {row.lot} + + {row.priceFrom} →{" "} + {row.priceTo} + + + {row.date} + + + {row.exposure} + +
+ ))} +
{/* ── ДКП-сделки на улице ──────────────────────────────────── */} @@ -144,7 +158,8 @@ export default function HistoryView({ style={{ fontSize: "8.5px", letterSpacing: "2px", - color: tokens.muted2, + // C1: primary section eyebrow → body2 (ObjectSummary pattern). + color: tokens.body2, marginBottom: "5px", }} > @@ -205,7 +220,8 @@ export default function HistoryView({ style={{ fontSize: "9px", letterSpacing: "1.5px", - color: tokens.muted2, + // C1: primary KPI label → body2 (above the muted value/unit). + color: tokens.body2, }} > СДЕЛОК ЗА 24 МЕС @@ -232,7 +248,8 @@ export default function HistoryView({ style={{ fontSize: "9px", letterSpacing: "1.5px", - color: tokens.muted2, + // C1: primary KPI label → body2 (above the muted value/unit). + color: tokens.body2, }} > МЕДИАНА @@ -257,7 +274,8 @@ export default function HistoryView({ style={{ fontSize: "9px", letterSpacing: "1.5px", - color: tokens.muted2, + // C1: primary KPI label → body2 (above the muted value/unit). + color: tokens.body2, }} > ДИАПАЗОН @@ -280,86 +298,104 @@ export default function HistoryView({ {/* Table header */} -
- АДРЕС - ПЛОЩАДЬ - ЦЕНА СДЕЛКИ - ₽/М² - ДАТА - {showAsk && ОБЪЯВЛЕНИЕ ДО СДЕЛКИ} -
- - {data.dkpRows.map((r, i) => ( +
- {r.addr ?? "—"} - - {r.area} - - - {r.price} - - - {r.ppm} - - АДРЕС + ПЛОЩАДЬ + ЦЕНА СДЕЛКИ + ₽/М² + ДАТА + {showAsk && ОБЪЯВЛЕНИЕ ДО СДЕЛКИ} +
+ + {data.dkpRows.map((r, i) => ( +
- {r.date ?? "—"} - - {showAsk && ( + + {r.addr ?? "—"} + + {r.area} + + + {r.price} + + + {r.ppm} + + + {r.date ?? "—"} + + {showAsk && ( - {r.source ?? "—"} + + {r.source ?? "—"} + + {r.ask} + {r.delta} - {r.ask} - {r.delta} - - )} -
- ))} + )} +
+ ))} +
(null); + const lastFocused = useRef(null); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + // Focus management + Escape/Tab handling while open. Unlike SectionOverlay + // (mounted only when open) this drawer stays mounted for the slide animation, + // so the effect is keyed on `open`: trap focus on open, restore it on close. useEffect(() => { if (!open) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + const dialog = dialogRef.current; + // Remember the trigger, then move focus into the dialog. + lastFocused.current = document.activeElement; + dialog?.focus(); + + function getFocusable(): HTMLElement[] { + if (!dialog) return []; + const nodes = dialog.querySelectorAll( + 'button, [href], input, select, [tabindex]:not([tabindex="-1"])', + ); + return Array.from(nodes).filter((el) => !el.hasAttribute("disabled")); + } + + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + // Stop the event so a stacked overlay below can't also consume it and so + // the browser runs no default action; then close this (topmost) drawer. + e.preventDefault(); + e.stopPropagation(); + onCloseRef.current(); + return; + } + if (e.key !== "Tab" || !dialog) return; + + const focusable = getFocusable(); + if (focusable.length === 0) { + // Nothing tabbable inside — keep focus pinned on the dialog itself. + e.preventDefault(); + dialog.focus(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const activeEl = document.activeElement; + const inside = dialog.contains(activeEl); + + if (e.shiftKey) { + if (activeEl === first || activeEl === dialog || !inside) { + e.preventDefault(); + last.focus(); + } + } else if (activeEl === last || !inside) { + e.preventDefault(); + first.focus(); + } + } + + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + // Return focus to whatever opened the drawer. + (lastFocused.current as HTMLElement | null)?.focus?.(); }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [open, onClose]); + // `onClose` is read via onCloseRef; refs are stable → only `open` is reactive. + }, [open]); return ( <> @@ -37,6 +99,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) { width: 32px; height: 32px; flex: 0 0 auto; + padding: 0; border: 1px solid ${tokens.line}; border-radius: 6px; display: flex; @@ -44,7 +107,9 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) { justify-content: center; cursor: pointer; color: ${tokens.muted}; + font-family: inherit; font-size: 16px; + line-height: 1; background: ${tokens.surface.w60}; transition: all .15s; } @@ -69,13 +134,18 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) { }} /> - {/* drawer */} + {/* drawer — modal dialog (role / focus-trap / inert mirror SectionOverlay) */}
МЕТОДИКА
-
+
+
{/* divider under header */} @@ -181,7 +257,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) { style={{ fontSize: 9, letterSpacing: "2px", - color: tokens.muted2, + color: tokens.body2, marginTop: 22, marginBottom: 12, }} @@ -195,7 +271,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) { color: tokens.body2, }} > - Агрегируем объявления (Авито, Циан, Яндекс, Домклик) и сделки + Агрегируем объявления (Циан, Я.Недвижимость, Авито, N1.ru) и сделки Росреестра по сопоставимым квартирам. Медиана{" "} ₽/м² → 3 оценки:{" "} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx index 55ee2945..90489be3 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx @@ -18,6 +18,26 @@ interface ObjectSummaryProps { onNavigate: (i: number) => void; } +// Audit M11 — name the unnamed «N · XX,XX млн» numbers. Each summary row value +// is built in mappers.mapSummary as "count · metric"; the metric is different +// per row and the «млн» medians (Аналоги/Сделки) are NOT the headline median +// (e.median_price_rub is area-normalised to the subject; these are raw medians +// of the analog/deal listing prices), so users couldn't tell what they were. +// This caption labels each ·-separated token token-for-token. Keyed by label +// content so it matches both the mapper labels ("Продажи рядом") and the older +// fixture labels ("Продажи в доме"). Sources in mappers.mapSummary: +// Продажи рядом : houseSold · kpi.median_exposure_days +// Аналоги : e.n_analogs · median(e.analogs.map(a => a.price_rub)) +// Сделки : dealCount · streetDeals.median_price_rub ?? median(deals) +// Аналитика : kpi.median_exposure_days · -kpi.median_bargain_pct +function rowHint(label: string): string { + if (label.startsWith("Продажи")) return "кол-во · медиана экспозиции"; + if (label === "Аналоги") return "кол-во · медиана объявлений"; + if (label === "Сделки") return "кол-во · медиана сделок"; + if (label === "Аналитика") return "экспозиция · торг"; + return ""; +} + export function ObjectSummary({ data = OBJECT_SUMMARY_FIXTURE, onNavigate, @@ -109,7 +129,7 @@ export function ObjectSummary({ style={{ fontSize: 9, letterSpacing: 1.2, - color: tokens.muted3, + color: tokens.body2, marginBottom: 13, }} > @@ -158,7 +178,9 @@ export function ObjectSummary({ {/* Per-section totals (clickable) */}
- {data.summary.rows.map((row, i) => ( + {data.summary.rows + .filter((row) => row.value !== "—") + .map((row, i, visible) => (