"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 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 { 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(`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(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 (
{lat == null || lon == null ? "Карта появится после расчёта адреса" : "Не удалось загрузить карту"}
); } return (
); } /* 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 ( <> СКАЧАТЬ PDF-ОТЧЁТ ); }; return (
{/* LEFT: meta + buttons */}
ОТЧЁТ
{data.report.id}
ДАТА
{data.report.date}
{hasEstimate && (
ДЕЙСТВИТЕЛЕН ДО
{data.report.validUntil}
)}
{pdfHref ? ( {pdfBtnInner(pdfFilled)} ) : ( )} {hasEstimate && ( )}
{/* 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 && (
{/* 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. */}
{/* address card */}
АДРЕС
{data.object.address}
{data.object.city}
(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", }} > КОЭФ. ЛОКАЦИИ {data.object.locationCoef === "—" ? ( {/* #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. */} нет данных ) : ( {data.object.locationCoef} )}
{/* 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. */}
)}
); }