fix(tradein/ui): мини-карта адреса вместо статичного фото чужого дома в шапке (#2529)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m18s
Deploy Trade-In / deploy (push) Successful in 51s

This commit is contained in:
lekss361 2026-07-26 20:15:54 +00:00
parent e0b63cc637
commit 06bf8dfada
5 changed files with 196 additions and 145 deletions

View file

@ -135,8 +135,8 @@ const EMPTY_OBJECT: ObjectInfo = {
repair: "—", repair: "—",
balcony: false, balcony: false,
locationCoef: "—", locationCoef: "—",
streetView: "", lat: null,
compass: "", lon: null,
}; };
// Skeleton pulse (opacity only — NO shimmer sweep, per .claude/rules/ui-*) + // Skeleton pulse (opacity only — NO shimmer sweep, per .claude/rules/ui-*) +

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, type CSSProperties } from "react"; import { useEffect, useRef, useState, type CSSProperties } from "react";
import { API_BASE_URL } from "@/lib/api"; import { API_BASE_URL } from "@/lib/api";
import { safeUrl } from "@/lib/safeUrl"; import { safeUrl } from "@/lib/safeUrl";
@ -12,11 +12,6 @@ import type { HeroBarData } from "./mappers";
// Default presentation data (unwired usage): the existing design fixtures. // Default presentation data (unwired usage): the existing design fixtures.
const HERO_FIXTURE: HeroBarData = { report, object }; const HERO_FIXTURE: HeroBarData = { report, object };
// next/image does NOT prepend the configured basePath ("/trade-in") to a
// literal src, so an <Image src="/trade-in-v2/…"> 404s behind Caddy. A plain
// <img> 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 // estimate ids are server-issued UUIDs — reject anything else before it lands
// in the PDF request path so a tampered id cannot be injected. // in the PDF request path so a tampered id cannot be injected.
const PDF_UUID_RE = const PDF_UUID_RE =
@ -37,6 +32,157 @@ function pdfDownloadHref(estimateId: string | null | undefined): string | null {
: 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<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",
padding: "0 20px",
}}
>
{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 { interface HeroBarProps {
data?: HeroBarData; data?: HeroBarData;
estimateId?: string | null; estimateId?: string | null;
@ -46,10 +192,10 @@ interface HeroBarProps {
hasEstimate: boolean; hasEstimate: boolean;
onOpenInfo: () => void; onOpenInfo: () => void;
// #2275 mobile quick-view: a real fluid layout instead of the fixed-width // #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 // address/coef card baked into it) is dropped since it assumes a 560×152 box
// that cannot reflow. «КАК РАССЧИТАНО» still opens the same location-coef // 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; compact?: boolean;
} }
@ -92,9 +238,6 @@ export default function HeroBar({
// A downloadable report exists ⇔ the estimate is ready ⇒ the PDF button is the // A downloadable report exists ⇔ the estimate is ready ⇒ the PDF button is the
// filled/primary CTA (M4). Otherwise it stays a disabled outline. // filled/primary CTA (M4). Otherwise it stays a disabled outline.
const pdfFilled = Boolean(pdfHref); 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 pdfBtnInner = (filled: boolean) => {
const frame = filled ? "#fff" : "#2e8bff"; const frame = filled ? "#fff" : "#2e8bff";
const rule = filled ? "rgba(255,255,255,.75)" : "#6f8195"; 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-pdf-btn-filled:active { transform: translateY(1px); }
.hero-calc-btn:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; } .hero-calc-btn:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
.hero-coef-row:hover { background: rgba(46,139,255,.09); } .hero-coef-row:hover { background: rgba(46,139,255,.09); }
@keyframes hero-scanv { 0% { transform: translateY(-100%); } 100% { transform: translateY(900%); } }
`}</style> `}</style>
{/* LEFT: meta + buttons */} {/* LEFT: meta + buttons */}
@ -295,11 +437,15 @@ export default function HeroBar({
</div> </div>
</div> </div>
{/* RIGHT: photo + overlays dropped in compact mode (#2275): the box is {/* RIGHT: locator mini-map + overlays dropped in compact mode (#2275):
a fixed 560×152 with several absolutely-positioned children (address the box is a fixed 560×152 with several absolutely-positioned
card, compass, distance scale) pinned to that size, so it cannot children (address card) pinned to that size, so it cannot reflow to
reflow to a phone width. «КАК РАССЧИТАНО» above still opens the same a phone width. «КАК РАССЧИТАНО» above still opens the same
location-coef drawer, so no functionality is lost. */} 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 && ( {!compact && (
<div <div
style={{ style={{
@ -310,65 +456,26 @@ export default function HeroBar({
border: `1px solid ${tokens.line3}`, border: `1px solid ${tokens.line3}`,
borderRadius: 6, borderRadius: 6,
overflow: "hidden", overflow: "hidden",
background: tokens.photoBg, background: tokens.mapBg,
boxShadow: "0 6px 26px rgba(40,80,130,.10)", boxShadow: "0 6px 26px rgba(40,80,130,.10)",
}} }}
> >
{!imgFailed && ( <HeroMiniMap lat={data.object.lat} lon={data.object.lon} />
<img
src={`${BP}/trade-in-v2/building.png`} {/* Left fade so the address card (below) stays legible over busy
alt="" map tiles instead of a floating card with no visual anchor kept
onError={() => setImgFailed(true)} from the old photo styling, where it served the same purpose.
style={{ pointerEvents:none so it never blocks map interaction/attribution
position: "absolute", underneath. The old bottom vignette + scanning HUD sweep line are
inset: 0, dropped: both existed purely to stylise/recede the stock photo and
width: "100%", have no equivalent purpose over a live basemap. */}
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 && (
<div
style={{
position: "absolute",
inset: 0,
background: "rgba(46,139,255,.14)",
mixBlendMode: "multiply",
pointerEvents: "none",
}}
/>
)}
<div <div
style={{ style={{
position: "absolute", position: "absolute",
inset: 0, inset: 0,
background: background:
"linear-gradient(90deg,rgba(238,244,250,.96) 0%,rgba(238,244,250,.5) 22%,transparent 40%)", "linear-gradient(90deg,rgba(238,244,250,.96) 0%,rgba(238,244,250,.5) 22%,transparent 40%)",
}} pointerEvents: "none",
/>
<div
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(0deg,rgba(230,240,250,.45),transparent 40%)",
}}
/>
<div
style={{
position: "absolute",
left: 0,
right: 0,
top: "34%",
height: 1,
background:
"linear-gradient(90deg,transparent,rgba(46,139,255,.5),transparent)",
animation: "hero-scanv 6s linear infinite",
}} }}
/> />
@ -406,31 +513,6 @@ export default function HeroBar({
{data.object.city} {data.object.city}
</span> </span>
</div> </div>
{/* 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 && (
<>
<div
style={{
height: 1,
background: tokens.lineSoft,
margin: "8px 0",
}}
/>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 9,
color: tokens.muted,
lineHeight: 1.55,
}}
>
{data.object.streetView}
</div>
</>
)}
<div <div
style={{ style={{
height: 1, height: 1,
@ -528,54 +610,14 @@ export default function HeroBar({
</div> </div>
</div> </div>
{/* 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 && (
<div
style={{
position: "absolute",
right: 16,
top: 14,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 2,
color: tokens.accent,
}}
>
<svg
width="22"
height="22"
viewBox="0 0 22 22"
fill="none"
aria-hidden="true"
>
<circle cx="11" cy="11" r="10" stroke="#2e8bff" strokeWidth="1" />
<path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" />
</svg>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 8,
letterSpacing: ".5px",
color: tokens.muted,
whiteSpace: "nowrap",
}}
>
{data.object.compass}
</span>
</div>
)}
{/* Fix #3 (audit) the "0/25/50/75/100м" distance ruler here was a {/* 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 hardcoded tick scale over a generic stock photo with no real
measurement behind it (not tied to any actual distance/scale measurement behind it (not tied to any actual distance/scale
value). Removed rather than fabricating a 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. */}
<div <div
style={{ style={{
position: "absolute", position: "absolute",
@ -585,6 +627,7 @@ export default function HeroBar({
height: 14, height: 14,
borderLeft: `1.5px solid ${tokens.accent}`, borderLeft: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`, borderTop: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}} }}
/> />
<div <div
@ -596,6 +639,7 @@ export default function HeroBar({
height: 14, height: 14,
borderRight: `1.5px solid ${tokens.accent}`, borderRight: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`, borderTop: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}} }}
/> />
<div <div
@ -607,6 +651,7 @@ export default function HeroBar({
height: 14, height: 14,
borderLeft: `1.5px solid ${tokens.accent}`, borderLeft: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`, borderBottom: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}} }}
/> />
<div <div
@ -618,6 +663,7 @@ export default function HeroBar({
height: 14, height: 14,
borderRight: `1.5px solid ${tokens.accent}`, borderRight: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`, borderBottom: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}} }}
/> />
</div> </div>

View file

@ -45,8 +45,10 @@ export const object: ObjectInfo = {
repair: "Хороший", repair: "Хороший",
balcony: true, balcony: true,
locationCoef: "0.87", locationCoef: "0.87",
streetView: "Street View · май 2024", // Approximate центр Екатеринбурга near ул. Малышева, 30 — illustrative
compass: "СЕВЕРО-ЗАПАД", // fixture coordinate for the HeroBar locator mini-map (unwired usage only).
lat: 56.8384,
lon: 60.6057,
}; };
// ---- 02 RESULT ------------------------------------------------------------ // ---- 02 RESULT ------------------------------------------------------------

View file

@ -20,8 +20,6 @@
// (parseAddress). Backend should return structured address components. // (parseAddress). Backend should return structured address components.
// BE-3 location coefficient shipped 2026-07-03 (#2045) and is wired here // BE-3 location coefficient shipped 2026-07-03 (#2045) and is wired here
// (mapObject/mapLocation consume GET /trade-in/location-coef, #2317). // (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): // Enum <-> RU reconciliation (design dropdowns have options with no enum value):
// house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type) // 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] : "—", repair: e.repair_state ? REPAIR_RU[e.repair_state] : "—",
balcony: e.has_balcony ?? false, balcony: e.has_balcony ?? false,
locationCoef: coefDeltaLabel(coef), locationCoef: coefDeltaLabel(coef),
streetView: "", // TODO BE-3 (backend does not surface a street-view caption) // Same target_lat/target_lon the ParamsPanel/SourcesMap map pins use —
compass: "", // TODO BE-3 (backend does not surface a compass bearing) // null while the estimate has no geocode yet.
lat: e.target_lat,
lon: e.target_lon,
}; };
} }

View file

@ -19,8 +19,11 @@ export interface ObjectInfo {
repair: string; repair: string;
balcony: boolean; balcony: boolean;
locationCoef: string; locationCoef: string;
streetView: string; // Subject coordinates for the HeroBar locator mini-map (Leaflet/OSM). null
compass: string; // 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 ------------------------------------------------------------ // ---- 02 RESULT ------------------------------------------------------------