gendesign/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx
bot-backend 4ec6fc9970
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
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
fix(tradein/v2): заглушка карты в шапке заезжала под адресную карточку
После проставления карточке z-index: 750 (#2530) она стала перекрывать текст
заглушки «Карта появится после расчёта адреса», который центрировался по всему
блоку шириной 560px. Заглушке добавлен левый отступ 214px — ширина полосы
карточки (left:16 + width:182) плюс зазор. Центрирование теперь происходит в
свободной области справа от карточки. То же исправляет и состояние «Не удалось
загрузить карту», которое рендерится тем же блоком.

Замечено пользователем на проде.
2026-07-27 00:00:25 +03:00

690 lines
25 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";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { API_BASE_URL } from "@/lib/api";
import { safeUrl } from "@/lib/safeUrl";
import { tokens } from "./tokens";
import { object, report } from "./fixtures";
import type { HeroBarData } from "./mappers";
// Default presentation data (unwired usage): the existing design fixtures.
const HERO_FIXTURE: HeroBarData = { report, object };
// estimate ids are server-issued UUIDs — reject anything else before it lands
// in the PDF request path so a tampered id cannot be injected.
const PDF_UUID_RE =
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
// Build + validate the PDF download URL for an estimate id. Returns null when
// there is no estimate yet (→ disabled button), the id is not a UUID, or the
// resolved URL fails the safeUrl scheme allowlist. API_BASE_URL is relative
// ("" in dev, "/trade-in" behind Caddy), so resolve against the document origin
// purely for the safeUrl http/https check; the <a> keeps the relative href
// (the browser resolves it against the current page, like OfferCard's link).
function pdfDownloadHref(estimateId: string | null | undefined): string | null {
if (!estimateId || !PDF_UUID_RE.test(estimateId)) return null;
const href = `${API_BASE_URL}/api/v1/trade-in/estimate/${estimateId}/pdf`;
if (typeof window === "undefined") return href;
return safeUrl(new URL(href, window.location.origin).toString())
? href
: null;
}
// ── Locator mini-map (Leaflet + OSM) ────────────────────────────────────────
// Replaces the old static building.png stock photo — user-reported bug: that
// single asset was shown for EVERY estimate regardless of the real address,
// misleading users into thinking they were looking at their own building.
// PORTS the Leaflet-CDN loader pattern from ./SourcesMap.tsx (itself ported
// from the dead v1 tree's MapCard.tsx) — copied rather than imported so this
// file stays a self-contained port with no shared runtime module and no npm
// Leaflet dep, same rationale as SourcesMap.
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. SourcesMap.tsx) */
const LEAFLET_VER = "1.9.4";
const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`;
const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`;
const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror SourcesMap.tsx) */
function loadLeaflet(): Promise<any> {
return new Promise((resolve, reject) => {
const w = window as any;
if (w.L) {
resolve(w.L);
return;
}
if (!document.querySelector(`link[data-leaflet]`)) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = LEAFLET_CSS;
link.integrity = LEAFLET_CSS_SRI;
link.crossOrigin = "anonymous";
link.setAttribute("data-leaflet", "1");
document.head.appendChild(link);
}
const existing = document.querySelector<HTMLScriptElement>(`script[data-leaflet]`);
if (existing) {
existing.addEventListener("load", () => resolve(w.L));
existing.addEventListener("error", () => reject(new Error("leaflet load failed")));
return;
}
const script = document.createElement("script");
script.src = LEAFLET_JS;
script.integrity = LEAFLET_JS_SRI;
script.crossOrigin = "anonymous";
script.setAttribute("data-leaflet", "1");
script.onload = () => resolve(w.L);
script.onerror = () => reject(new Error("leaflet load failed"));
document.body.appendChild(script);
});
}
// Overview zoom: close enough to recognise the actual building on a 560×152
// box without feeling zoomed-in on bare rooftops (SourcesMap's multi-pin
// overlay map fits bounds instead — this is a single-point locator, not an
// exploration map).
const HERO_MAP_ZOOM = 16;
interface HeroMiniMapProps {
lat: number | null;
lon: number | null;
}
/**
* Small OSM/Leaflet locator map centred on the subject address, replacing the
* old static building.png photo. Deliberately near-non-interactive — this is
* a 560×152 "you are here" badge, not an explorable map (ParamsPanel/
* SourcesMap already cover that): dragging/zoomControl/scroll-zoom/dbl-click
* zoom are all off so the box reads as a locator, not a broken-feeling mini
* map, and never steals the page's scroll or click focus.
*/
function HeroMiniMap({ lat, lon }: HeroMiniMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const [mapError, setMapError] = useState(false);
useEffect(() => {
if (lat == null || lon == null) return;
let map: any = null;
let cancelled = false;
// Reset a stale failure from a previous address/CDN hiccup before this
// attempt — otherwise one bad load latches mapError forever (caught in
// review on a sibling PR) and every later estimate shows the placeholder
// even once the CDN is reachable again.
setMapError(false);
loadLeaflet()
.then((L) => {
if (cancelled || !mapRef.current) return;
map = L.map(mapRef.current, {
scrollWheelZoom: false, // embedded in the page — must not steal page scroll
dragging: false, // locator badge, not an explorable map
touchZoom: false,
doubleClickZoom: false,
zoomControl: false, // no room for +/- controls at this size
keyboard: false,
}).setView([lat, lon], HERO_MAP_ZOOM);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© OpenStreetMap",
maxZoom: 19,
}).addTo(map);
L.circleMarker([lat, lon], {
radius: 8,
color: "#fff",
weight: 3,
fillColor: tokens.accent,
fillOpacity: 1,
}).addTo(map);
setTimeout(() => map && map.invalidateSize(), 120);
})
.catch(() => {
if (!cancelled) setMapError(true);
});
return () => {
cancelled = true;
if (map) map.remove();
};
}, [lat, lon]);
// Honest empty states — no coordinates yet (fresh/un-geocoded estimate) or
// the CDN failed — never a blank rectangle or a broken-image icon.
if (lat == null || lon == null || mapError) {
return (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: tokens.mapBg,
color: tokens.muted,
fontSize: 11,
textAlign: "center",
// Left padding clears the address card's lane (left:16 + width:182 =
// 198, plus a gutter). The card sits at z-index 750 and would
// otherwise cover this centred text — reported on prod right after
// the z-index fix landed. Centring happens inside the content box,
// so the text now centres in the free area to the right of the card.
padding: "0 20px 0 214px",
}}
>
{lat == null || lon == null
? "Карта появится после расчёта адреса"
: "Не удалось загрузить карту"}
</div>
);
}
return (
<div
ref={mapRef}
style={{ position: "absolute", inset: 0, background: tokens.mapBg }}
aria-label="Расположение объекта на карте"
/>
);
}
/* eslint-enable @typescript-eslint/no-explicit-any */
interface HeroBarProps {
data?: HeroBarData;
estimateId?: string | null;
// M4: when false (no real, sufficient estimate yet) the estimate-dependent
// meta/controls — the «ДЕЙСТВИТЕЛЕН ДО» validity line and «КАК РАССЧИТАНО» —
// are hidden, mirroring how the PDF control already gates on estimate presence.
hasEstimate: boolean;
onOpenInfo: () => void;
// #2275 mobile quick-view: a real fluid layout instead of the fixed-width
// desktop one — meta/buttons stack, the locator mini-map (and the
// address/coef card baked into it) is dropped since it assumes a 560×152 box
// that cannot reflow. «КАК РАССЧИТАНО» still opens the same location-coef
// drawer, so no functionality is lost, only the redundant map-card copy.
compact?: boolean;
}
const pdfBtnStyle: CSSProperties = {
flex: 1,
height: 50,
background: tokens.surface.w55,
border: `1px solid ${tokens.line}`,
borderRadius: 7,
display: "flex",
alignItems: "center",
gap: 11,
padding: "0 16px",
cursor: "pointer",
fontFamily: tokens.font.sans,
color: tokens.ink,
textDecoration: "none",
transition: "all .15s",
};
// M4 — once a result is on screen the primary action is «СКАЧАТЬ PDF-ОТЧЁТ»
// (re-running the estimate is secondary), so the download becomes the single
// filled/primary button and «ОЦЕНИТЬ КВАРТИРУ» demotes to outline (ParamsPanel).
const pdfFilledStyle: CSSProperties = {
...pdfBtnStyle,
background: `linear-gradient(90deg, ${tokens.accentDeep}, ${tokens.accent})`,
border: `1px solid ${tokens.accent}`,
color: tokens.onAccent,
boxShadow: "0 2px 10px rgba(46,139,255,.25)",
};
export default function HeroBar({
data = HERO_FIXTURE,
estimateId,
hasEstimate,
onOpenInfo,
compact = false,
}: HeroBarProps) {
const pdfHref = hasEstimate ? pdfDownloadHref(estimateId) : null;
// A downloadable report exists ⇔ the estimate is ready ⇒ the PDF button is the
// filled/primary CTA (M4). Otherwise it stays a disabled outline.
const pdfFilled = Boolean(pdfHref);
const pdfBtnInner = (filled: boolean) => {
const frame = filled ? "#fff" : "#2e8bff";
const rule = filled ? "rgba(255,255,255,.75)" : "#6f8195";
return (
<>
<svg
width="18"
height="20"
viewBox="0 0 18 20"
fill="none"
aria-hidden="true"
>
<path d="M2 1h9l5 5v13H2z" stroke={frame} strokeWidth="1.2" />
<path d="M11 1v5h5" stroke={frame} strokeWidth="1.2" />
<line x1="5" y1="11" x2="13" y2="11" stroke={rule} />
<line x1="5" y1="14" x2="13" y2="14" stroke={rule} />
</svg>
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: "1px" }}>
СКАЧАТЬ PDF-ОТЧЁТ
</span>
</>
);
};
return (
<div
style={{
display: "flex",
flexDirection: compact ? "column" : "row",
gap: compact ? 16 : 30,
padding: compact ? "0" : "14px 2px 12px",
flex: "0 0 auto",
}}
>
<style>{`
.hero-pdf-btn:hover { border-color: ${tokens.accent}; background: rgba(46,139,255,.07); }
.hero-pdf-btn:active { transform: translateY(1px); }
.hero-pdf-btn-filled { transition: all .18s; }
.hero-pdf-btn-filled:hover { background: ${tokens.accentDeep}; box-shadow: 0 6px 22px rgba(46,139,255,.4); }
.hero-pdf-btn-filled:active { transform: translateY(1px); }
.hero-calc-btn:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
.hero-coef-row:hover { background: rgba(46,139,255,.09); }
`}</style>
{/* LEFT: meta + buttons */}
<div
style={{
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
justifyContent: "center",
gap: compact ? 14 : 22,
}}
>
<div
style={{
display: "flex",
flexWrap: compact ? "wrap" : "nowrap",
gap: compact ? 18 : 26,
rowGap: compact ? 8 : undefined,
alignItems: "flex-start",
}}
>
<div>
<div
style={{
fontSize: 9,
letterSpacing: "2px",
color: tokens.muted2,
marginBottom: 6,
}}
>
ОТЧЁТ
</div>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 14,
color: tokens.ink,
}}
>
{data.report.id}
</div>
</div>
<div
style={{
borderLeft: `1px solid ${tokens.lineSoft}`,
paddingLeft: 26,
}}
>
<div
style={{
fontSize: 9,
letterSpacing: "2px",
color: tokens.muted2,
marginBottom: 6,
}}
>
ДАТА
</div>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 14,
color: tokens.ink,
}}
>
{data.report.date}
</div>
</div>
{hasEstimate && (
<div
style={{
borderLeft: `1px solid ${tokens.lineSoft}`,
paddingLeft: 26,
}}
>
<div
style={{
fontSize: 9,
letterSpacing: "2px",
color: tokens.muted2,
marginBottom: 6,
}}
>
ДЕЙСТВИТЕЛЕН ДО
</div>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 14,
color: tokens.ink,
}}
>
{data.report.validUntil}
</div>
</div>
)}
</div>
<div
style={{
display: "flex",
flexDirection: compact ? "column" : "row",
gap: 12,
width: "100%",
maxWidth: compact ? "none" : 440,
}}
>
{pdfHref ? (
<a
className={pdfFilled ? "hero-pdf-btn-filled" : "hero-pdf-btn"}
href={pdfHref}
target="_blank"
rel="noopener noreferrer"
style={pdfFilled ? pdfFilledStyle : pdfBtnStyle}
>
{pdfBtnInner(pdfFilled)}
</a>
) : (
<button
type="button"
disabled
title="Отчёт станет доступен после расчёта оценки"
style={{ ...pdfBtnStyle, cursor: "not-allowed", opacity: 0.45 }}
>
{pdfBtnInner(false)}
</button>
)}
{hasEstimate && (
<button
type="button"
className="hero-calc-btn"
onClick={onOpenInfo}
style={{
flex: 1,
height: 50,
background: tokens.surface.w40,
border: `1px solid ${tokens.line}`,
borderRadius: 7,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
fontFamily: tokens.font.sans,
color: tokens.muted,
fontSize: 11,
fontWeight: 600,
letterSpacing: "1.5px",
transition: "all .15s",
}}
>
КАК РАССЧИТАНО
</button>
)}
</div>
</div>
{/* RIGHT: locator mini-map + overlays — dropped in compact mode (#2275):
the box is a fixed 560×152 with several absolutely-positioned
children (address card) pinned to that size, so it cannot reflow to
a phone width. «КАК РАССЧИТАНО» above still opens the same
location-coef drawer, so no functionality is lost.
User-reported bug: this used to be a single static building.png
photo shown for EVERY estimate regardless of the real address (a
user could be looking at someone else's building) — replaced with a
real Leaflet/OSM map centred on the subject's own coordinates. */}
{!compact && (
<div
style={{
position: "relative",
width: 560,
height: 152,
flex: "0 0 auto",
border: `1px solid ${tokens.line3}`,
borderRadius: 6,
overflow: "hidden",
background: tokens.mapBg,
boxShadow: "0 6px 26px rgba(40,80,130,.10)",
}}
>
<HeroMiniMap lat={data.object.lat} lon={data.object.lon} />
{/* Left fade so the address card (below) stays legible over busy
map tiles instead of a floating card with no visual anchor — kept
from the old photo styling, where it served the same purpose.
pointerEvents:none so it never blocks map interaction/attribution
underneath. The old bottom vignette + scanning HUD sweep line are
dropped: both existed purely to stylise/recede the stock photo and
have no equivalent purpose over a live basemap. */}
<div
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(90deg,rgba(238,244,250,.96) 0%,rgba(238,244,250,.5) 22%,transparent 40%)",
pointerEvents: "none",
// Above Leaflet's tile pane (200) / overlay pane (400); below the
// marker (600) so the subject pin still reads through the fade.
zIndex: 500,
}}
/>
{/* address card */}
<div
style={{
position: "absolute",
left: 16,
top: "50%",
transform: "translateY(-50%)",
width: 182,
// MUST be set explicitly. .leaflet-container is position:relative
// with z-index:auto, so it does NOT open a stacking context — its
// internal panes (tiles 200 … popup 700) compete directly with
// these siblings, and an auto/0 card is painted UNDER the map.
// Shipped without this in #2529 and the address + location figure
// vanished from the hero on prod. 750 clears every pane; the
// attribution control (800) sits bottom-right and never overlaps
// this left-anchored card, so its link stays clickable.
zIndex: 750,
background: tokens.surface.w72,
backdropFilter: "blur(5px)",
border: `1px solid ${tokens.line}`,
borderRadius: 5,
padding: "11px 13px",
}}
>
<div
style={{
fontSize: 9,
letterSpacing: "2.5px",
color: tokens.muted2,
marginBottom: 3,
}}
>
АДРЕС
</div>
<div style={{ fontSize: 14, fontWeight: 600, lineHeight: 1.3 }}>
{data.object.address}
<br />
<span
style={{ fontSize: 11, fontWeight: 400, color: tokens.muted }}
>
{data.object.city}
</span>
</div>
<div
style={{
height: 1,
background: tokens.lineSoft,
margin: "8px 0",
}}
/>
<div
// role=button + tabIndex + Enter/Space (#2264 C6): keyboard-operable
// without switching the element to a <button> (which, with preflight
// off, would need a UA-chrome reset and risk the row's exact box
// geometry / negative-margin hover bleed). Opens the methodology drawer.
role="button"
tabIndex={0}
className="hero-coef-row"
onClick={onOpenInfo}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenInfo();
}
}}
aria-label="Пояснение к расчёту коэффициента локации"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
cursor: "pointer",
borderRadius: 4,
margin: "-3px -5px",
padding: "3px 5px",
transition: "background .15s",
}}
>
<span
style={{
fontSize: "8.5px",
letterSpacing: "1.5px",
color: tokens.muted2,
}}
>
КОЭФ. ЛОКАЦИИ
</span>
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
{data.object.locationCoef === "—" ? (
<span
style={{
fontSize: "9px",
letterSpacing: ".5px",
// body2 (not muted2): муть на badgeTint давала 2.3:1 < AA;
// body2 на badgeTint ≈ 6.3:1 — читаемо, остаётся «приглушённым».
color: tokens.body2,
background: tokens.badgeTint,
border: `1px solid ${tokens.line2}`,
borderRadius: 10,
padding: "1px 8px",
}}
>
{/* #2317: coef is a live feature now (GET /location-coef) — a
dash here means unavailable/loading for THIS estimate
(unavailable geo_source, no lat/lon, or query pending),
never "not built yet", so «скоро» would be stale/false. */}
нет данных
</span>
) : (
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 15,
fontWeight: 500,
color: tokens.accent,
}}
>
{data.object.locationCoef}
</span>
)}
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 14,
height: 14,
border: `1px solid ${tokens.accent}`,
borderRadius: "50%",
fontSize: "8.5px",
color: tokens.accent,
fontWeight: 600,
}}
aria-hidden="true"
>
?
</span>
</span>
</div>
</div>
{/* Fix #3 (audit) — the "0/25/50/75/100м" distance ruler here was a
hardcoded tick scale over a generic stock photo with no real
measurement behind it (not tied to any actual distance/scale
value). Removed rather than fabricating a scale. */}
{/* corner brackets — decorative HUD framing only; pointerEvents:none
so they never sit on top of the map's own bottom-right OSM
attribution link. */}
<div
style={{
position: "absolute",
left: 8,
top: 8,
width: 14,
height: 14,
borderLeft: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}}
/>
<div
style={{
position: "absolute",
right: 8,
top: 8,
width: 14,
height: 14,
borderRight: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}}
/>
<div
style={{
position: "absolute",
left: 8,
bottom: 8,
width: 14,
height: 14,
borderLeft: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}}
/>
<div
style={{
position: "absolute",
right: 8,
bottom: 8,
width: 14,
height: 14,
borderRight: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}}
/>
</div>
)}
</div>
);
}