Merge remote-tracking branch 'forgejo/main' into feat/tradein-web-chat-backend
All checks were successful
CI / changes (pull_request) Successful in 17s
CI Trade-In / changes (pull_request) Successful in 18s
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) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m12s

This commit is contained in:
bot-backend 2026-07-26 23:34:39 +03:00
commit ed12d9e657
6 changed files with 587 additions and 368 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-*) +
@ -681,6 +681,13 @@ export default function TradeInV2Page() {
house_type: estimate.house_type ?? undefined,
repair_state: estimate.repair_state ?? undefined,
has_balcony: estimate.has_balcony ?? undefined,
// Without these the 01 map falls back to its "pick an address"
// placeholder on every restored estimate (shared link, history,
// PDF flow) even though the address field is populated — the map
// keys off coords, not the address string, and only the geocode
// suggestion path used to supply them.
lat: estimate.target_lat ?? undefined,
lon: estimate.target_lon ?? undefined,
}
: undefined,
[estimate],

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,29 @@ 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",
// Above Leaflet's tile pane (200) / overlay pane (400); below the
// marker (600) so the subject pin still reads through the fade.
zIndex: 500,
}}
/>
@ -380,6 +490,15 @@ export default function HeroBar({
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}`,
@ -406,31 +525,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 +622,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 +639,7 @@ export default function HeroBar({
height: 14,
borderLeft: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}}
/>
<div
@ -596,6 +651,7 @@ export default function HeroBar({
height: 14,
borderRight: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}}
/>
<div
@ -607,6 +663,7 @@ export default function HeroBar({
height: 14,
borderLeft: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}}
/>
<div
@ -618,6 +675,7 @@ export default function HeroBar({
height: 14,
borderRight: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}}
/>
</div>

View file

@ -7,9 +7,16 @@
// styles are UNCHANGED — only the data plumbing differs (display <div>s became
// <input>s styled identically, dropdowns now feed real enum values). RU dropdown
// labels <-> API enum values go through HOUSE_TYPE_*/REPAIR_* maps in ./mappers.
// РАДИУС is now wired (radius_m on submit + the outer map ring scales with it);
// the CRM dropdown still has no backend → kept visually but disabled. Hover/active
// + @keyframes live in a pp-prefixed local <style>.
// РАДИУС is now wired (radius_m on submit + the real map circle below scales
// with it); the CRM dropdown still has no backend → kept visually but disabled.
// Hover/active + @keyframes live in a pp-prefixed local <style>.
//
// Fix (audit): the 01 map was a decorative SVG (grid + fake streets + radius
// rings) with no real coordinates behind it. It is now a real Leaflet + OSM
// basemap, ported from the same CDN-loader pattern as ./SourcesMap.tsx /
// ../MapPicker.tsx (copied rather than shared — same self-contained-port
// convention as SourcesMap.tsx, no npm Leaflet dep).
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. SourcesMap.tsx) */
import {
useEffect,
@ -143,6 +150,97 @@ function comboKeyDown(
}
}
// ── 01 map — Leaflet + OSM (real coordinates) ───────────────────────────────
// Same CDN loader pattern/version/SRI as ./SourcesMap.tsx and ../MapPicker.tsx
// (duplicated on purpose — each v2 file is a self-contained port, no shared
// runtime module, no npm Leaflet dep).
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=";
const MAP_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const MAP_ATTRIBUTION = "© OpenStreetMap";
const DEFAULT_MAP_ZOOM = 16;
const MIN_MAP_ZOOM = 11;
const MAX_MAP_ZOOM = 19;
/** Подгружает 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);
});
}
/** Экранирование перед вставкой в raw-HTML Leaflet divIcon (адрес ввод
* пользователя). Тот же паттерн, что и esc() в SourcesMap.tsx. */
function escapeMapHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
c === "&"
? "&amp;"
: c === "<"
? "&lt;"
: c === ">"
? "&gt;"
: c === '"'
? "&quot;"
: "&#39;",
);
}
/** Пин квартиры-предмета оценки divIcon 1:1 повторяет прежний SVG-бабл
* (акцентная рамка, точка, адрес + площадь, треугольник-указатель), только
* теперь висит на реальных координатах реальной карты. */
function buildSubjectIcon(
L: any,
addressLabel: string,
areaLabel: string | null,
): any {
const html = `
<div style="position:relative;width:220px;height:56px;pointer-events:none">
<div style="position:absolute;left:50%;bottom:9px;transform:translateX(-50%);display:flex;align-items:center;gap:6px;background:${tokens.surface.w85};border:1px solid ${tokens.accent};border-radius:4px;padding:4px 8px;white-space:nowrap;box-shadow:0 3px 10px rgba(46,139,255,.25);font-family:${tokens.font.sans}">
<span style="width:7px;height:7px;border-radius:50%;background:${tokens.accent};flex:0 0 auto"></span>
<span style="display:flex;flex-direction:column;gap:1px">
<span style="font-size:11px;font-weight:600;color:${tokens.ink}">${escapeMapHtml(addressLabel)}</span>
${
areaLabel
? `<span style="font-size:9px;color:${tokens.muted}">${escapeMapHtml(areaLabel)}</span>`
: ""
}
</span>
</div>
<div style="position:absolute;left:50%;bottom:0;transform:translateX(-50%);width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-top:7px solid ${tokens.accent}"></div>
</div>`;
return L.divIcon({ html, className: "", iconSize: [220, 56], iconAnchor: [110, 56] });
}
function Dd({
open,
onToggle,
@ -425,21 +523,6 @@ const errorText: CSSProperties = {
color: tokens.danger,
};
// Analog price pin on the 01 map. left/top come from mapMarkers() per-analog
// (projected from the real estimate); the chrome matches the former fixture
// pins 1:1 — only the data source changed (Finding #2).
const analogPin: CSSProperties = {
position: "absolute",
background: tokens.surface.w70,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
padding: "3px 7px",
fontFamily: tokens.font.mono,
fontSize: 9,
lineHeight: 1.4,
color: tokens.ink,
};
// РАДИУС dropdown panel — mirrors the <Dd> HUD panel (surface.w98 + soft blue
// shadow), sized to the narrow radius trigger and dropped just beneath it.
const radiusPanel: CSSProperties = {
@ -468,8 +551,13 @@ interface ParamsPanelProps {
error?: string | null;
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
initialValues?: Partial<TradeInEstimateInput>;
/** Analog price pins for the 01 map, projected from the real estimate via
* mapMarkers(). Default [] no price pins (never the fabricated fixtures). */
/** Analog price pins, projected from the real estimate via mapMarkers() onto
* the OLD decorative SVG's fixed 0-100% grid (never a real geo scale see
* mapMarkers() comment in ./mappers.ts). Kept in the prop contract for
* backward-compat with the caller (app/v2/page.tsx); intentionally NOT
* plotted on the real Leaflet map below, because their %-positions do not
* correspond to real lat/lon at the map's actual zoom projecting them
* would be a new, subtler version of the honesty bug this map replaces. */
markers?: MapMarker[];
}
@ -495,6 +583,12 @@ function initRepairLabel(rs: RepairState | undefined): string {
// both ("ищем строго в пределах X м"). Design dropdown was values-only.
const RADIUS_OPTIONS = ["Авто", "300 м", "500 м", "1000 м", "2000 м"];
// "Авто" sends no radius_m → the backend applies its two-tier default (1000 m
// primary / 2000 m fallback, see RADIUS_OPTIONS comment above). The map circle
// previews the PRIMARY tier so it is never wildly off from what the backend
// will actually use.
const AUTO_RADIUS_PREVIEW_M = 1000;
// radius_m (metres) -> dropdown label. null/absent → "Авто" (legacy two-tier
// default), so a re-submit never silently narrows the search.
function initRadiusLabel(radiusM: number | null | undefined): string {
@ -509,7 +603,9 @@ export default function ParamsPanel({
hasEstimate = false,
error = null,
initialValues,
markers = [],
// markers intentionally not destructured — see the ParamsPanelProps.markers
// doc comment: its %-positions belong to the retired decorative SVG grid and
// do not correspond to real lat/lon on the Leaflet map below.
}: ParamsPanelProps) {
// M4 — once a result is on screen the primary CTA is «СКАЧАТЬ PDF-ОТЧЁТ»
// (HeroBar); this button (a re-run) demotes to a secondary outline.
@ -518,11 +614,6 @@ export default function ParamsPanel({
// РАДИУС combobox a11y state (aria-activedescendant highlight + listbox id).
const radiusListId = useId();
const [radiusActive, setRadiusActive] = useState(-1);
// Map zoom — applied as transform: scale() on the map content layer (not the
// whole panel). Default 1 (identity → pixel-identical), step .25, clamp 12.5
// (no zoom-out below 1: there is no real basemap behind the blueprint SVG, so
// shrinking would only expose empty corners — zoom-in only).
const [zoom, setZoom] = useState(1);
const [address, setAddress] = useState(initialValues?.address ?? "");
const [area, setArea] = useState(
initialValues?.area_m2 != null ? String(initialValues.area_m2) : "",
@ -776,21 +867,138 @@ export default function ParamsPanel({
transition: "all .15s",
};
// Outer radius ring tracks the selected РАДИУС (subtle, best-effort): 500 м
// keeps the design's r=112 exactly; other values scale gently (and clamp) so the
// ring never blows past the map box. The inner two rings stay fixed.
const radiusM = parseInt(radius, 10);
const outerRingR = Number.isFinite(radiusM)
? Math.round(
Math.max(70, Math.min(155, 112 * Math.pow(radiusM / 500, 0.35))),
)
: 112;
// Real analysis-radius circle (metres), synced with the selected РАДИУС —
// L.circle takes a radius in metres, so this is a true geographic scale
// (unlike the old SVG ring, which was a clamped pixel best-effort).
const parsedRadiusM = parseInt(radius, 10);
const circleRadiusM = Number.isFinite(parsedRadiusM)
? parsedRadiusM
: AUTO_RADIUS_PREVIEW_M;
// Subject area caption for the map pin (M10) — the SUBJECT's own m², from the
// form, so the pin never borrows an analog's area. Empty area → no caption.
const areaTrimmed = area.trim();
const subjectAreaLabel = areaTrimmed ? `${areaTrimmed} м²` : null;
// ── 01 map — real Leaflet + OSM (see loadLeaflet()/buildSubjectIcon() above) ──
const hasCoords = coords != null;
const mapContainerRef = useRef<HTMLDivElement>(null);
const leafletMapRef = useRef<any>(null);
const markerRef = useRef<any>(null);
const circleRef = useRef<any>(null);
const [mapZoom, setMapZoom] = useState(DEFAULT_MAP_ZOOM);
const [mapLoadError, setMapLoadError] = useState(false);
// "Latest value" refs, read inside the async loadLeaflet().then() callback
// below, which may resolve several renders after the effect fired (slow CDN
// load). Plain closure vars would go stale; refs assigned every render don't.
const addressRef = useRef(address);
addressRef.current = address;
const subjectAreaLabelRef = useRef(subjectAreaLabel);
subjectAreaLabelRef.current = subjectAreaLabel;
const circleRadiusMRef = useRef(circleRadiusM);
circleRadiusMRef.current = circleRadiusM;
const coordsRef = useRef(coords);
coordsRef.current = coords;
// Create/destroy the map when coordinates appear/disappear (not on every
// lat/lon value change — see the position-sync effect below for that). This
// is the only effect that mounts/tears down the Leaflet instance, so it is
// also the only place map.remove() needs to run, fixing the "Map container
// is already initialized" crash class.
useEffect(() => {
if (!hasCoords) return; // no coords yet -> the placeholder renders instead
let map: any = null;
let cancelled = false;
// Reset a previous CDN failure before retrying. Without this the error
// branch is a dead end: it replaces the map container in the render tree,
// so mapContainerRef stays null and no later attempt can ever succeed —
// one transient unpkg blip would kill the map for the rest of the session
// even after the user picks a different address.
setMapLoadError(false);
loadLeaflet()
.then((L) => {
if (cancelled || !mapContainerRef.current || !coordsRef.current) return;
const center: [number, number] = [
coordsRef.current.lat,
coordsRef.current.lon,
];
map = L.map(mapContainerRef.current, {
scrollWheelZoom: false,
zoomControl: false,
minZoom: MIN_MAP_ZOOM,
maxZoom: MAX_MAP_ZOOM,
}).setView(center, DEFAULT_MAP_ZOOM);
L.tileLayer(MAP_TILE_URL, {
attribution: MAP_ATTRIBUTION,
maxZoom: MAX_MAP_ZOOM,
}).addTo(map);
setTimeout(() => map && map.invalidateSize(), 120);
circleRef.current = L.circle(center, {
radius: circleRadiusMRef.current,
color: tokens.accent,
weight: 1,
fillColor: tokens.accent,
fillOpacity: 0.05,
interactive: false,
}).addTo(map);
markerRef.current = L.marker(center, {
icon: buildSubjectIcon(
L,
addressRef.current.trim() || "Адрес квартиры",
subjectAreaLabelRef.current,
),
interactive: false,
}).addTo(map);
leafletMapRef.current = map;
setMapZoom(DEFAULT_MAP_ZOOM);
map.on("zoomend", () => setMapZoom(map.getZoom()));
})
.catch(() => setMapLoadError(true));
return () => {
cancelled = true;
if (map) map.remove();
leafletMapRef.current = null;
markerRef.current = null;
circleRef.current = null;
};
// hasCoords is the only plain dependency this effect reads directly — the
// live lat/lon/radius/address values come from refs (assigned every
// render above), which exhaustive-deps correctly treats as stable.
}, [hasCoords]);
// Move the existing map/marker/circle to a newly picked address without
// tearing the map down (avoids a tile-reload flash on every pick).
useEffect(() => {
const map = leafletMapRef.current;
if (!map || !coords) return;
const center: [number, number] = [coords.lat, coords.lon];
map.setView(center, map.getZoom());
markerRef.current?.setLatLng(center);
circleRef.current?.setLatLng(center);
}, [coords]);
// Sync the radius circle in place when the РАДИУС АНАЛИЗА selection changes.
useEffect(() => {
circleRef.current?.setRadius(circleRadiusM);
}, [circleRadiusM]);
// Refresh the pin's address/area caption in place as the user edits the form.
useEffect(() => {
if (!markerRef.current) return;
const w = window as any;
if (!w.L) return;
markerRef.current.setIcon(
buildSubjectIcon(w.L, address.trim() || "Адрес квартиры", subjectAreaLabel),
);
}, [address, subjectAreaLabel]);
// Address combobox render state (M6). `popupOpen` = the dropdown is shown at
// all (incl. loading / empty notes); `listboxOpen` = it holds real selectable
// options, which is when the input advertises aria-expanded + activedescendant.
@ -870,7 +1078,12 @@ export default function ParamsPanel({
</div>
</div>
{/* MAP */}
{/* MAP real Leaflet + OSM basemap, centred on the geocoded address
(Fix, audit #2264 C7 follow-up): was a decorative SVG (grid + fake
streets + a clamped best-effort radius ring), no real coordinates
behind it at all. © OpenStreetMap attribution comes from Leaflet's
own attribution control on the tile layer below (OSM tile licence
requirement) no separate caption needed. */}
<div
style={{
position: "relative",
@ -882,189 +1095,142 @@ export default function ParamsPanel({
flex: "0 0 auto",
}}
>
{/* zoomable map content — scaled by the +/ controls. The map controls
and corner bracket below are SIBLINGS (outside this layer) so they
never scale. zoom=1 scale() identity pixel-identical to design. */}
<div
style={{
position: "absolute",
inset: 0,
transform: `scale(${zoom})`,
transformOrigin: "center",
transition: "transform .15s ease-out",
}}
>
<svg
viewBox="0 0 440 210"
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
}}
>
<rect width="440" height="210" fill="#e6edf4" />
<g stroke="#dbe5ef" strokeWidth={1}>
<path d="M0 38H440M0 78H440M0 118H440M0 158H440M0 198H440" />
<path d="M40 0V210M110 0V210M180 0V210M250 0V210M320 0V210M390 0V210" />
</g>
<g stroke="#f5f8fc" strokeWidth={8} strokeLinecap="round">
<path d="M-10 92 H450" />
<path d="M150 -10 V220" />
<path d="M-10 30 L260 0" />
</g>
<g stroke="#eef3f9" strokeWidth={5}>
<path d="M300 -10 L470 150" />
<path d="M-10 170 H450" />
</g>
<path d="M150 92 L470 200" stroke="#cfe0f3" strokeWidth={6} />
<g fill="none" stroke="#2e8bff" strokeDasharray="3 4">
<circle cx="208" cy="100" r="44" opacity={0.55} />
<circle cx="208" cy="100" r="80" opacity={0.4} />
<circle cx="208" cy="100" r={outerRingR} opacity={0.28} />
</g>
<circle cx="208" cy="100" r="80" fill="#2e8bff" opacity={0.04} />
</svg>
{/* center pin */}
{hasCoords ? (
mapLoadError ? (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0 16px",
textAlign: "center",
fontSize: 10.5,
lineHeight: 1.4,
color: tokens.hint,
fontFamily: tokens.font.sans,
}}
>
Не удалось загрузить карту. Проверьте интернет-соединение.
</div>
) : (
<>
<div
ref={mapContainerRef}
style={{ position: "absolute", inset: 0 }}
aria-label="Карта расположения квартиры"
/>
{/* map controls real Leaflet zoom (Leaflet's own zoomControl
is disabled above; these are the HUD-styled buttons). */}
<div
style={{
position: "absolute",
right: 9,
bottom: 9,
display: "flex",
flexDirection: "column",
gap: 5,
}}
>
<button
type="button"
aria-label="Приблизить карту"
disabled={mapZoom >= MAX_MAP_ZOOM}
style={{
width: 24,
height: 24,
background: tokens.surface.w80,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: mapZoom >= MAX_MAP_ZOOM ? "default" : "pointer",
transition: "all .15s",
fontSize: 15,
color: tokens.muted,
opacity: mapZoom >= MAX_MAP_ZOOM ? 0.4 : 1,
padding: 0,
fontFamily: "inherit",
}}
onClick={() => leafletMapRef.current?.zoomIn()}
>
+
</button>
<button
type="button"
aria-label="Отдалить карту"
disabled={mapZoom <= MIN_MAP_ZOOM}
style={{
width: 24,
height: 24,
background: tokens.surface.w80,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: mapZoom <= MIN_MAP_ZOOM ? "default" : "pointer",
transition: "all .15s",
fontSize: 15,
color: tokens.muted,
opacity: mapZoom <= MIN_MAP_ZOOM ? 0.4 : 1,
padding: 0,
fontFamily: "inherit",
}}
onClick={() => leafletMapRef.current?.zoomOut()}
>
</button>
</div>
</>
)
) : (
/* No coordinates yet (address not typed/picked from suggest) a
tidy placeholder, never an empty grey box or a broken map. */
<div
style={{
position: "absolute",
left: "47%",
top: "48%",
transform: "translate(-50%,-100%)",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 6,
padding: "0 16px",
textAlign: "center",
}}
>
<div
<svg
aria-hidden="true"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M12 21s-7-7.2-7-12a7 7 0 1 1 14 0c0 4.8-7 12-7 12Z"
stroke={tokens.muted2}
strokeWidth={1.5}
/>
<circle cx="12" cy="9" r="2.5" stroke={tokens.muted2} strokeWidth={1.5} />
</svg>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
background: tokens.surface.w85,
border: `1px solid ${tokens.accent}`,
borderRadius: 4,
padding: "4px 8px",
whiteSpace: "nowrap",
boxShadow: "0 3px 10px rgba(46,139,255,.25)",
fontSize: 10.5,
lineHeight: 1.4,
color: tokens.hint,
fontFamily: tokens.font.sans,
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: tokens.accent,
}}
/>
{/* M10: the SUBJECT pin must show the subject's OWN address + area,
never an analog's. The form area (subjectAreaLabel) is rendered
here so an analog pin that lands near the centre can no longer be
mistaken for the subject's caption. */}
<span
style={{ display: "flex", flexDirection: "column", gap: 1 }}
>
<span style={{ fontSize: 11, fontWeight: 600 }}>
{address.trim() || "Адрес квартиры"}
</span>
{subjectAreaLabel && (
<span style={{ fontSize: 9, color: tokens.muted }}>
{subjectAreaLabel} · оцениваемая квартира
</span>
)}
</span>
</div>
<div
style={{
width: 0,
height: 0,
borderLeft: "5px solid transparent",
borderRight: "5px solid transparent",
borderTop: `7px solid ${tokens.accent}`,
}}
/>
Выберите адрес из подсказок, чтобы увидеть карту
</span>
</div>
)}
{/* analog price pins projected from the real estimate via mapMarkers().
Empty markers (no estimate yet) no price pins, just the subject pin +
radius rings (Finding #2: never the fabricated fixture prices/dots). */}
{markers.map((m, i) => (
<div key={i} style={{ ...analogPin, left: m.left, top: m.top }}>
{m.label}
<br />
<span style={{ color: tokens.muted }}>{m.sub}</span>
</div>
))}
</div>
{/* map controls */}
<div
style={{
position: "absolute",
right: 9,
bottom: 9,
display: "flex",
flexDirection: "column",
gap: 5,
}}
>
<button
type="button"
aria-label="Приблизить карту"
style={{
width: 24,
height: 24,
background: tokens.surface.w80,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all .15s",
fontSize: 15,
color: tokens.muted,
padding: 0,
fontFamily: "inherit",
}}
onClick={() => setZoom((z) => Math.min(2.5, z + 0.25))}
>
+
</button>
<button
type="button"
aria-label="Отдалить карту"
disabled={zoom <= 1}
style={{
width: 24,
height: 24,
background: tokens.surface.w80,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: zoom <= 1 ? "default" : "pointer",
transition: "all .15s",
fontSize: 15,
color: tokens.muted,
opacity: zoom <= 1 ? 0.4 : 1,
padding: 0,
fontFamily: "inherit",
}}
onClick={() => {
if (zoom > 1) setZoom((z) => Math.max(1, z - 0.25));
}}
>
</button>
</div>
{/* map corner bracket */}
{/* map corner bracket decorative HUD chrome, shown regardless of
map/placeholder state (unchanged from the prior design). */}
<div
style={{
position: "absolute",
@ -1078,23 +1244,6 @@ export default function ParamsPanel({
/>
</div>
{/* Fix #7 (audit) the SVG above is decorative (grid + fake streets +
radius rings), not a real map, and the rings themselves aren't drawn
to scale (outerRingR is a clamped best-effort, not a true geometric
projection of РАДИУС). Same honesty-caption tone as SourcesMap's
footer disclosure. */}
<div
style={{
fontSize: 9,
color: tokens.hint,
lineHeight: 1.4,
marginTop: 6,
flex: "0 0 auto",
}}
>
Схематично · не географическая карта · радиус не в масштабе
</div>
{/* radius row */}
<div
style={{

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