fix(tradein/ui): заменить статичное фото чужого дома в HeroBar на мини-карту адреса
All checks were successful
CI Trade-In / frontend-checks (pull_request) Successful in 1m13s
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

Пользователь видел одну и ту же фотографию здания (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 оставшихся использований.
This commit is contained in:
bot-backend 2026-07-26 23:10:51 +03:00
parent d56103219a
commit 90e946a0c6
5 changed files with 196 additions and 145 deletions

View file

@ -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-*) +

View file

@ -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 <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
// 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<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 {
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%); } }
`}</style>
{/* LEFT: meta + buttons */}
@ -295,11 +437,15 @@ export default function HeroBar({
</div>
</div>
{/* 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 && (
<div
style={{
@ -310,65 +456,26 @@ export default function HeroBar({
border: `1px solid ${tokens.line3}`,
borderRadius: 6,
overflow: "hidden",
background: tokens.photoBg,
background: tokens.mapBg,
boxShadow: "0 6px 26px rgba(40,80,130,.10)",
}}
>
{!imgFailed && (
<img
src={`${BP}/trade-in-v2/building.png`}
alt=""
onError={() => 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 && (
<div
style={{
position: "absolute",
inset: 0,
background: "rgba(46,139,255,.14)",
mixBlendMode: "multiply",
pointerEvents: "none",
}}
/>
)}
<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%)",
}}
/>
<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",
pointerEvents: "none",
}}
/>
@ -406,31 +513,6 @@ export default function HeroBar({
{data.object.city}
</span>
</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
style={{
height: 1,
@ -528,54 +610,14 @@ export default function HeroBar({
</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
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. */}
<div
style={{
position: "absolute",
@ -585,6 +627,7 @@ export default function HeroBar({
height: 14,
borderLeft: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}}
/>
<div
@ -596,6 +639,7 @@ export default function HeroBar({
height: 14,
borderRight: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}}
/>
<div
@ -607,6 +651,7 @@ export default function HeroBar({
height: 14,
borderLeft: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}}
/>
<div
@ -618,6 +663,7 @@ export default function HeroBar({
height: 14,
borderRight: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}}
/>
</div>

View file

@ -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 ------------------------------------------------------------

View file

@ -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,
};
}

View file

@ -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 ------------------------------------------------------------