From 90e946a0c6f323d3443d646f6296be4ac590385c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 26 Jul 2026 23:10:51 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/ui):=20=D0=B7=D0=B0=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D1=84=D0=BE=D1=82=D0=BE=20=D1=87=D1=83?= =?UTF-8?q?=D0=B6=D0=BE=D0=B3=D0=BE=20=D0=B4=D0=BE=D0=BC=D0=B0=20=D0=B2=20?= =?UTF-8?q?HeroBar=20=D0=BD=D0=B0=20=D0=BC=D0=B8=D0=BD=D0=B8-=D0=BA=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D1=83=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пользователь видел одну и ту же фотографию здания (building.png) для ЛЮБОГО адреса оценки — на странице оценки конкретной квартиры показывался чужой дом, и клиент об этом не знал. Ставим Leaflet/OSM мини-карту (560x152), отцентрованную на target_lat/target_lon оценки, с маркером дома; карточка адреса поверх карты сохранена. Ресёрч альтернатив: настоящих панорам без коммерческой лицензии Яндекса (от ~195 тыс/год) не получить, у 2ГИС публичного API панорам нет, покрытие Google по ЕКБ дырявое и не обновлялось с 2022, Mapillary — комплаенс-риск для B2B. Фото из объявлений юридически небезопасны (прецедент ФАС Avito/Cian). Заодно убраны мёртвые слоты streetView/compass — backend их никогда не отдавал (TODO BE-3), грепом подтверждено 0 оставшихся использований. --- tradein-mvp/frontend/src/app/v2/page.tsx | 4 +- .../src/components/trade-in/v2/HeroBar.tsx | 316 ++++++++++-------- .../src/components/trade-in/v2/fixtures.ts | 6 +- .../src/components/trade-in/v2/mappers.ts | 8 +- .../src/components/trade-in/v2/types.ts | 7 +- 5 files changed, 196 insertions(+), 145 deletions(-) diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 01aeff26..91f13305 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -135,8 +135,8 @@ const EMPTY_OBJECT: ObjectInfo = { repair: "—", balcony: false, locationCoef: "—", - streetView: "", - compass: "", + lat: null, + lon: null, }; // Skeleton pulse (opacity only — NO shimmer sweep, per .claude/rules/ui-*) + diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx index 0a89cd51..069602b9 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, type CSSProperties } from "react"; +import { useEffect, useRef, useState, type CSSProperties } from "react"; import { API_BASE_URL } from "@/lib/api"; import { safeUrl } from "@/lib/safeUrl"; @@ -12,11 +12,6 @@ import type { HeroBarData } from "./mappers"; // Default presentation data (unwired usage): the existing design fixtures. const HERO_FIXTURE: HeroBarData = { report, object }; -// next/image does NOT prepend the configured basePath ("/trade-in") to a -// literal src, so an 404s behind Caddy. A plain -// with the basePath baked in resolves to /trade-in/trade-in-v2/… → 200. -const BP = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; - // 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 = @@ -37,6 +32,157 @@ function pdfDownloadHref(estimateId: string | null | undefined): string | null { : 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; @@ -46,10 +192,10 @@ interface HeroBarProps { hasEstimate: boolean; onOpenInfo: () => void; // #2275 mobile quick-view: a real fluid layout instead of the fixed-width - // desktop one — meta/buttons stack, the decorative building photo (and the + // 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 photo-card copy. + // drawer, so no functionality is lost, only the redundant map-card copy. compact?: boolean; } @@ -92,9 +238,6 @@ export default function HeroBar({ // 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); - // Hide the building photo if the asset 404s/400s so the photoBg fill shows - // instead of a broken-image icon. - const [imgFailed, setImgFailed] = useState(false); const pdfBtnInner = (filled: boolean) => { const frame = filled ? "#fff" : "#2e8bff"; const rule = filled ? "rgba(255,255,255,.75)" : "#6f8195"; @@ -137,7 +280,6 @@ export default function HeroBar({ .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); } - @keyframes hero-scanv { 0% { transform: translateY(-100%); } 100% { transform: translateY(900%); } } `} {/* LEFT: meta + buttons */} @@ -295,11 +437,15 @@ export default function HeroBar({
- {/* RIGHT: photo + overlays — dropped in compact mode (#2275): the box is - a fixed 560×152 with several absolutely-positioned children (address - card, compass, distance scale) 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. */} + {/* 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 && (
- {!imgFailed && ( - setImgFailed(true)} - style={{ - position: "absolute", - inset: 0, - width: "100%", - height: "100%", - objectFit: "contain", - objectPosition: "right center", - filter: "saturate(.25) brightness(1.06) contrast(.95)", - }} - /> - )} - {/* blue duotone tint toward HUD accent — recedes the photo (only over - the real image; skip when it 404s so the placeholder stays clean) */} - {!imgFailed && ( -
- )} + + + {/* 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. */}
-
-
@@ -406,31 +513,6 @@ export default function HeroBar({ {data.object.city}
- {/* streetView — Fix #4 (audit): always "" (TODO BE-3, mappers.ts - mapObject), so this slot + its divider are hidden together - rather than showing an empty caption row. Leaves the single - divider below to separate the address block from the coef row. */} - {data.object.streetView && ( - <> -
-
- {data.object.streetView} -
- - )}
- {/* compass — Fix #4 (audit): compass bearing is always "" (TODO BE-3, - mappers.ts mapObject), so the icon+label are hidden rather than - showing a compass that never actually points anywhere. Mirrors the - locationCoef "нет данных" graceful-fallback pattern already used - in this file. */} - {data.object.compass && ( -
- - - {data.object.compass} - -
- )} - {/* 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 */} + {/* corner brackets — decorative HUD framing only; pointerEvents:none + so they never sit on top of the map's own bottom-right OSM + attribution link. */}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts b/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts index bfa3af50..a56dbdb3 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts @@ -45,8 +45,10 @@ export const object: ObjectInfo = { repair: "Хороший", balcony: true, locationCoef: "0.87", - streetView: "Street View · май 2024", - compass: "СЕВЕРО-ЗАПАД", + // Approximate центр Екатеринбурга near ул. Малышева, 30 — illustrative + // fixture coordinate for the HeroBar locator mini-map (unwired usage only). + lat: 56.8384, + lon: 60.6057, }; // ---- 02 RESULT ------------------------------------------------------------ diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index 40afb5aa..eac886b6 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -20,8 +20,6 @@ // (parseAddress). Backend should return structured address components. // BE-3 location coefficient shipped 2026-07-03 (#2045) and is wired here // (mapObject/mapLocation consume GET /trade-in/location-coef, #2317). -// street-view caption / compass bearing are still not in the API → -// remain placeholders. // // Enum <-> RU reconciliation (design dropdowns have options with no enum value): // house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type) @@ -820,8 +818,10 @@ export function mapObject( repair: e.repair_state ? REPAIR_RU[e.repair_state] : "—", balcony: e.has_balcony ?? false, locationCoef: coefDeltaLabel(coef), - streetView: "", // TODO BE-3 (backend does not surface a street-view caption) - compass: "", // TODO BE-3 (backend does not surface a compass bearing) + // Same target_lat/target_lon the ParamsPanel/SourcesMap map pins use — + // null while the estimate has no geocode yet. + lat: e.target_lat, + lon: e.target_lon, }; } diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts index b8ce9886..4db48392 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts @@ -19,8 +19,11 @@ export interface ObjectInfo { repair: string; balcony: boolean; locationCoef: string; - streetView: string; - compass: string; + // Subject coordinates for the HeroBar locator mini-map (Leaflet/OSM). null + // when the estimate has no geocode yet — the map then renders an honest + // "нет координат" placeholder instead of an empty/broken box. + lat: number | null; + lon: number | null; } // ---- 02 RESULT ------------------------------------------------------------