gendesign/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx
bot-backend f5a7f9b437
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 audit round 2 — AA contrast, honesty, a11y (#2081)
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 + <button> close + Esc preventDefault/stopPropagation.
M6 (ParamsPanel): address autocomplete -> combobox ARIA + Arrow/Home/End/
  Enter/Escape keyboard nav (mirrors Dd listbox).
M7 (Sources/Cache/Analytics/History): ARIA table semantics over the grids;
  Analytics scroll region keyboard-focusable (clears the serious
  scrollable-region-focusable axe violation); chart SVGs get role=img+title+aria-label.
M8 (CacheView): normalize the ВРЕМЯ column (normTime) to consistent minute precision.
M9 (LocationDrawer): honest «как считаем» source list (Циан, Я.Недвижимость,
  Авито, N1.ru + Росреестр) — drop dead Домклик/Restate per #1968.
M10 (ParamsPanel/mapMarkers): subject pin shows the subject's own area; drop
  analog pins overlapping the subject center (was misread as subject data).
M11 (ObjectSummary/SourcesView): label «медиана объявлений»; hide all-«—» columns/rows.
M13 (page): clamp artboard scale >=0.88 so micro-type doesn't drop below ~8px.
L1 (Analytics): suppress fake «X–Y дн» range when n<2.
L2 (Analytics): drop zero-data series from price-history subtitle.
L7 (TopNav): replace hardcoded #6f8195 icon strokes with tokens.muted.

tsc + next build green. No FE test infra in tradein-mvp/frontend — parseAddress
verified via standalone harness (6/6). Backend data-contract items M1/M2/M3/H4
(source counter, ratio vs medians, bargain bases, cross-source dedup) filed
separately — not FE bugs. L6 (blueprint-grid axe-blindness) deferred: base
gradient + glass surfaces keep axe contrast «incomplete» regardless, so the
authoritative check stays the manual computed-color sweep.
2026-06-29 01:01:30 +03:00

486 lines
15 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";
// Top navigation bar for the /trade-in/v2 "МЕРА Оценка" design port.
// Faithful markup port of the design header (МЕРА Оценка.dc.html, lines 42-90):
// inline SVG logo + version + 5 nav tabs (active underline/triangle) + user menu.
// Tabs change only local UI state via onNavigate; the user dropdown owns its
// own useState. No data fetching — labels/version come from fixtures, the user
// identity is fed in from the page (real useMe), colours from tokens.
import { useState } from "react";
import type { CSSProperties } from "react";
import { tokens } from "./tokens";
import { navLabels, version } from "./fixtures";
// Real logged-in user identity, derived by the page from useMe()
// ({username, role, brand}). Deliberately excludes `reports` — the «Мои
// отчёты» badge count is a separate prop fed from useQuota().used.
interface TopNavUser {
name: string;
org: string;
email: string;
initials: string;
}
interface TopNavProps {
active: number;
onNavigate: (i: number) => void;
// «Мои отчёты» badge count — page feeds it from useQuota().used (per-user
// estimate count). Defaults to 0 for unwired usage.
reports?: number;
// Real logged-in user. undefined → neutral «Гость» placeholder, NEVER the
// old design fixture ("Андрей Петров / Брусника").
user?: TopNavUser;
// Sign out — page wires this to logout(); closes the menu first.
onLogout?: () => void;
}
// Neutral fallback when the user prop is absent (loading / unauthenticated).
const GUEST_USER: TopNavUser = {
name: "Гость",
org: "—",
email: "—",
initials: "—",
};
const menuItemStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: "10px",
padding: "9px 10px",
borderRadius: "6px",
cursor: "pointer",
fontSize: "12.5px",
color: tokens.body,
transition: "background .12s",
};
// Профиль / Настройки / Помощь have no pages yet — render them dimmed and
// non-interactive (no hover class, default cursor) so they read as disabled.
const menuItemDisabledStyle: CSSProperties = {
...menuItemStyle,
cursor: "default",
color: tokens.muted2,
};
export default function TopNav({
active,
onNavigate,
reports = 0,
user,
onLogout,
}: TopNavProps) {
const [userOpen, setUserOpen] = useState(false);
const u = user ?? GUEST_USER;
return (
<>
<style>{`
.tnav-tab { color: ${tokens.muted}; font-weight: 400; cursor: pointer; transition: color .2s; }
.tnav-tab:hover { color: ${tokens.ink}; }
.tnav-tab--active { color: ${tokens.ink}; font-weight: 600; }
.tnav-userbtn { background: none; border: none; }
.tnav-userbtn:hover { background: rgba(46,139,255,.07); }
.tnav-menuitem:hover { background: rgba(46,139,255,.09); }
.tnav-logout { background: none; border: none; }
.tnav-logout:hover { background: rgba(205,104,104,.1); }
`}</style>
<div
style={{
height: "54px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: "1px solid rgba(150,175,200,.4)",
flex: "0 0 auto",
}}
>
{/* Logo + version */}
<div style={{ display: "flex", alignItems: "center", gap: "13px" }}>
<svg
width="184"
height="53"
viewBox="100 440 812 238"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g transform="translate(0,-20)">
<path
d="M164 520h250M610 520h250"
stroke="#c2d3e3"
strokeWidth="2.5"
fill="none"
/>
<path
d="M436 520h78"
stroke="#2e8bff"
strokeWidth="4"
fill="none"
/>
<circle cx="548" cy="520" r="5" fill="#2e8bff" />
<circle cx="790" cy="520" r="5" fill="#2e8bff" opacity=".75" />
<path
d="M118 502v36M906 502v36"
stroke="#c2d3e3"
strokeWidth="2"
fill="none"
opacity=".7"
/>
<path
d="M112 520h22M890 520h22"
stroke="#2e8bff"
strokeWidth="3"
fill="none"
opacity=".75"
/>
<text
x="512"
y="572"
textAnchor="middle"
style={{ fontFamily: tokens.font.sans }}
fontSize="150"
fontWeight="300"
letterSpacing="16"
fill="#17263a"
>
МЕРА
</text>
<text
x="512"
y="672"
textAnchor="middle"
style={{ fontFamily: tokens.font.sans }}
fontSize="27"
fontWeight="400"
letterSpacing="11"
fill="#6f8195"
>
ОЦЕНКА НЕДВИЖИМОСТИ
</text>
</g>
</svg>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: "9.5px",
letterSpacing: "1px",
color: tokens.muted2,
borderLeft: `1px solid ${tokens.line}`,
paddingLeft: "12px",
}}
>
{version}
</div>
</div>
{/* Nav tabs */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "24px",
fontSize: "11px",
letterSpacing: "1px",
}}
>
{navLabels.map((label, i) => (
<button
type="button"
key={label}
onClick={() => onNavigate(i)}
aria-current={active === i ? "page" : undefined}
className={`tnav-tab${active === i ? " tnav-tab--active" : ""}`}
style={{
position: "relative",
whiteSpace: "nowrap",
background: "none",
border: "none",
padding: 0,
margin: 0,
// NB: family/size/letterSpacing inherit for chrome reset, but do
// NOT set `font`/`color` shorthand — the .tnav-tab(--active)/:hover
// CSS must keep driving tab colour + weight.
fontFamily: "inherit",
fontSize: "inherit",
letterSpacing: "inherit",
cursor: "pointer",
}}
>
{label}
<div
style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
bottom: "-16px",
width: "30px",
height: "2px",
background: tokens.accent,
opacity: active === i ? 1 : 0,
transition: "opacity .2s",
}}
/>
{i === 0 && (
<div
style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
bottom: "-22px",
width: 0,
height: 0,
borderLeft: "3px solid transparent",
borderRight: "3px solid transparent",
borderTop: `4px solid ${tokens.accent}`,
opacity: active === 0 ? 1 : 0,
}}
/>
)}
</button>
))}
</div>
{/* User menu */}
<div
style={{ position: "relative" }}
onKeyDown={(e) => {
if (e.key === "Escape") setUserOpen(false);
}}
>
<button
type="button"
onClick={() => setUserOpen((o) => !o)}
className="tnav-userbtn"
aria-haspopup="true"
aria-expanded={userOpen}
style={{
display: "flex",
alignItems: "center",
gap: "10px",
cursor: "pointer",
padding: "5px 8px 5px 6px",
borderRadius: "9px",
transition: "background .15s",
fontFamily: "inherit",
textAlign: "left",
}}
>
<div
style={{
width: "34px",
height: "34px",
borderRadius: "50%",
background: `linear-gradient(135deg, ${tokens.accentLite}, ${tokens.accentDeep})`,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: tokens.onAccent,
fontSize: "12px",
fontWeight: 600,
fontFamily: tokens.font.sans,
letterSpacing: ".5px",
boxShadow: "0 3px 10px rgba(46,139,255,.3)",
}}
>
{u.initials}
</div>
<div style={{ lineHeight: 1.25 }}>
<div
style={{
fontSize: "12.5px",
fontWeight: 600,
color: tokens.ink,
fontFamily: tokens.font.sans,
}}
>
{u.name}
</div>
<div
style={{
fontSize: "9px",
color: tokens.muted2,
fontFamily: tokens.font.sans,
letterSpacing: ".5px",
}}
>
{u.org}
</div>
</div>
<span style={{ color: tokens.muted2, fontSize: "9px" }}></span>
</button>
{userOpen && (
<div
style={{
position: "absolute",
right: 0,
top: "calc(100% + 9px)",
width: "236px",
background: tokens.surface.w99,
border: `1px solid ${tokens.line}`,
borderRadius: "9px",
boxShadow: "0 16px 40px rgba(40,80,130,.2)",
zIndex: 50,
padding: "7px",
fontFamily: tokens.font.sans,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "11px",
padding: "10px 10px 12px",
borderBottom: `1px solid ${tokens.lineSoft2}`,
marginBottom: "6px",
}}
>
<div
style={{
width: "38px",
height: "38px",
borderRadius: "50%",
background: `linear-gradient(135deg, ${tokens.accentLite}, ${tokens.accentDeep})`,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: tokens.onAccent,
fontSize: "13px",
fontWeight: 600,
}}
>
{u.initials}
</div>
<div>
<div
style={{
fontSize: "13px",
fontWeight: 600,
color: tokens.ink2,
}}
>
{u.name}
</div>
<div style={{ fontSize: "10px", color: tokens.muted2 }}>
{u.email}
</div>
</div>
</div>
<div style={menuItemDisabledStyle} aria-disabled="true">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<circle
cx="7.5"
cy="5"
r="3"
stroke={tokens.muted}
strokeWidth="1.2"
/>
<path
d="M2 13c0-3 2.5-4.5 5.5-4.5S13 10 13 13"
stroke={tokens.muted}
strokeWidth="1.2"
/>
</svg>
Профиль
</div>
<div className="tnav-menuitem" style={menuItemStyle}>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<path
d="M3 1.5h6l3 3v9H3z"
stroke={tokens.muted}
strokeWidth="1.2"
/>
<path d="M9 1.5v3h3" stroke={tokens.muted} strokeWidth="1.2" />
</svg>
Мои отчёты{" "}
<span
style={{
marginLeft: "auto",
fontFamily: tokens.font.mono,
fontSize: "10px",
color: tokens.accent,
}}
>
{reports}
</span>
</div>
<div style={menuItemDisabledStyle} aria-disabled="true">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<circle
cx="7.5"
cy="7.5"
r="2.3"
stroke={tokens.muted}
strokeWidth="1.2"
/>
<path
d="M7.5 1v2M7.5 12v2M1 7.5h2M12 7.5h2"
stroke={tokens.muted}
strokeWidth="1.2"
/>
</svg>
Настройки
</div>
<div style={menuItemDisabledStyle} aria-disabled="true">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<circle
cx="7.5"
cy="7.5"
r="6"
stroke={tokens.muted}
strokeWidth="1.2"
/>
<path
d="M5.8 5.8a1.7 1.7 0 1 1 2.4 2.1c-.5.4-.7.7-.7 1.3M7.5 11.2v.2"
stroke={tokens.muted}
strokeWidth="1.2"
/>
</svg>
Помощь
</div>
<div
style={{
height: "1px",
background: tokens.lineSoft2,
margin: "6px 4px",
}}
/>
<button
type="button"
onClick={() => {
setUserOpen(false);
onLogout?.();
}}
className="tnav-logout"
style={{
...menuItemStyle,
color: tokens.danger,
width: "100%",
fontFamily: "inherit",
textAlign: "left",
}}
>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<path
d="M9 1.5H2.5v12H9M6 7.5h7M10.5 5l2.5 2.5L10.5 10"
stroke="#cd6868"
strokeWidth="1.2"
/>
</svg>
Выйти
</button>
</div>
)}
</div>
</div>
</>
);
}