All checks were successful
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
CI Trade-In / frontend-checks (pull_request) Successful in 1m13s
После проставления карточке z-index: 750 (#2530) она стала перекрывать текст заглушки «Карта появится после расчёта адреса», который центрировался по всему блоку шириной 560px. Заглушке добавлен левый отступ 214px — ширина полосы карточки (left:16 + width:182) плюс зазор. Центрирование теперь происходит в свободной области справа от карточки. То же исправляет и состояние «Не удалось загрузить карту», которое рендерится тем же блоком. Замечено пользователем на проде.
690 lines
25 KiB
TypeScript
690 lines
25 KiB
TypeScript
"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 <a> 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<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",
|
||
// Left padding clears the address card's lane (left:16 + width:182 =
|
||
// 198, plus a gutter). The card sits at z-index 750 and would
|
||
// otherwise cover this centred text — reported on prod right after
|
||
// the z-index fix landed. Centring happens inside the content box,
|
||
// so the text now centres in the free area to the right of the card.
|
||
padding: "0 20px 0 214px",
|
||
}}
|
||
>
|
||
{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;
|
||
// 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 (
|
||
<>
|
||
<svg
|
||
width="18"
|
||
height="20"
|
||
viewBox="0 0 18 20"
|
||
fill="none"
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M2 1h9l5 5v13H2z" stroke={frame} strokeWidth="1.2" />
|
||
<path d="M11 1v5h5" stroke={frame} strokeWidth="1.2" />
|
||
<line x1="5" y1="11" x2="13" y2="11" stroke={rule} />
|
||
<line x1="5" y1="14" x2="13" y2="14" stroke={rule} />
|
||
</svg>
|
||
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: "1px" }}>
|
||
СКАЧАТЬ PDF-ОТЧЁТ
|
||
</span>
|
||
</>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: compact ? "column" : "row",
|
||
gap: compact ? 16 : 30,
|
||
padding: compact ? "0" : "14px 2px 12px",
|
||
flex: "0 0 auto",
|
||
}}
|
||
>
|
||
<style>{`
|
||
.hero-pdf-btn:hover { border-color: ${tokens.accent}; background: rgba(46,139,255,.07); }
|
||
.hero-pdf-btn:active { transform: translateY(1px); }
|
||
.hero-pdf-btn-filled { transition: all .18s; }
|
||
.hero-pdf-btn-filled:hover { background: ${tokens.accentDeep}; box-shadow: 0 6px 22px rgba(46,139,255,.4); }
|
||
.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); }
|
||
`}</style>
|
||
|
||
{/* LEFT: meta + buttons */}
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
minWidth: 0,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
justifyContent: "center",
|
||
gap: compact ? 14 : 22,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexWrap: compact ? "wrap" : "nowrap",
|
||
gap: compact ? 18 : 26,
|
||
rowGap: compact ? 8 : undefined,
|
||
alignItems: "flex-start",
|
||
}}
|
||
>
|
||
<div>
|
||
<div
|
||
style={{
|
||
fontSize: 9,
|
||
letterSpacing: "2px",
|
||
color: tokens.muted2,
|
||
marginBottom: 6,
|
||
}}
|
||
>
|
||
ОТЧЁТ
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontFamily: tokens.font.mono,
|
||
fontSize: 14,
|
||
color: tokens.ink,
|
||
}}
|
||
>
|
||
{data.report.id}
|
||
</div>
|
||
</div>
|
||
<div
|
||
style={{
|
||
borderLeft: `1px solid ${tokens.lineSoft}`,
|
||
paddingLeft: 26,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontSize: 9,
|
||
letterSpacing: "2px",
|
||
color: tokens.muted2,
|
||
marginBottom: 6,
|
||
}}
|
||
>
|
||
ДАТА
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontFamily: tokens.font.mono,
|
||
fontSize: 14,
|
||
color: tokens.ink,
|
||
}}
|
||
>
|
||
{data.report.date}
|
||
</div>
|
||
</div>
|
||
{hasEstimate && (
|
||
<div
|
||
style={{
|
||
borderLeft: `1px solid ${tokens.lineSoft}`,
|
||
paddingLeft: 26,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontSize: 9,
|
||
letterSpacing: "2px",
|
||
color: tokens.muted2,
|
||
marginBottom: 6,
|
||
}}
|
||
>
|
||
ДЕЙСТВИТЕЛЕН ДО
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontFamily: tokens.font.mono,
|
||
fontSize: 14,
|
||
color: tokens.ink,
|
||
}}
|
||
>
|
||
{data.report.validUntil}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: compact ? "column" : "row",
|
||
gap: 12,
|
||
width: "100%",
|
||
maxWidth: compact ? "none" : 440,
|
||
}}
|
||
>
|
||
{pdfHref ? (
|
||
<a
|
||
className={pdfFilled ? "hero-pdf-btn-filled" : "hero-pdf-btn"}
|
||
href={pdfHref}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={pdfFilled ? pdfFilledStyle : pdfBtnStyle}
|
||
>
|
||
{pdfBtnInner(pdfFilled)}
|
||
</a>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
disabled
|
||
title="Отчёт станет доступен после расчёта оценки"
|
||
style={{ ...pdfBtnStyle, cursor: "not-allowed", opacity: 0.45 }}
|
||
>
|
||
{pdfBtnInner(false)}
|
||
</button>
|
||
)}
|
||
{hasEstimate && (
|
||
<button
|
||
type="button"
|
||
className="hero-calc-btn"
|
||
onClick={onOpenInfo}
|
||
style={{
|
||
flex: 1,
|
||
height: 50,
|
||
background: tokens.surface.w40,
|
||
border: `1px solid ${tokens.line}`,
|
||
borderRadius: 7,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
cursor: "pointer",
|
||
fontFamily: tokens.font.sans,
|
||
color: tokens.muted,
|
||
fontSize: 11,
|
||
fontWeight: 600,
|
||
letterSpacing: "1.5px",
|
||
transition: "all .15s",
|
||
}}
|
||
>
|
||
КАК РАССЧИТАНО
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 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={{
|
||
position: "relative",
|
||
width: 560,
|
||
height: 152,
|
||
flex: "0 0 auto",
|
||
border: `1px solid ${tokens.line3}`,
|
||
borderRadius: 6,
|
||
overflow: "hidden",
|
||
background: tokens.mapBg,
|
||
boxShadow: "0 6px 26px rgba(40,80,130,.10)",
|
||
}}
|
||
>
|
||
<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%)",
|
||
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,
|
||
}}
|
||
/>
|
||
|
||
{/* address card */}
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
left: 16,
|
||
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}`,
|
||
borderRadius: 5,
|
||
padding: "11px 13px",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontSize: 9,
|
||
letterSpacing: "2.5px",
|
||
color: tokens.muted2,
|
||
marginBottom: 3,
|
||
}}
|
||
>
|
||
АДРЕС
|
||
</div>
|
||
<div style={{ fontSize: 14, fontWeight: 600, lineHeight: 1.3 }}>
|
||
{data.object.address}
|
||
<br />
|
||
<span
|
||
style={{ fontSize: 11, fontWeight: 400, color: tokens.muted }}
|
||
>
|
||
{data.object.city}
|
||
</span>
|
||
</div>
|
||
<div
|
||
style={{
|
||
height: 1,
|
||
background: tokens.lineSoft,
|
||
margin: "8px 0",
|
||
}}
|
||
/>
|
||
<div
|
||
// role=button + tabIndex + Enter/Space (#2264 C6): keyboard-operable
|
||
// without switching the element to a <button> (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",
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
fontSize: "8.5px",
|
||
letterSpacing: "1.5px",
|
||
color: tokens.muted2,
|
||
}}
|
||
>
|
||
КОЭФ. ЛОКАЦИИ
|
||
</span>
|
||
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||
{data.object.locationCoef === "—" ? (
|
||
<span
|
||
style={{
|
||
fontSize: "9px",
|
||
letterSpacing: ".5px",
|
||
// body2 (not muted2): муть на badgeTint давала 2.3:1 < AA;
|
||
// body2 на badgeTint ≈ 6.3:1 — читаемо, остаётся «приглушённым».
|
||
color: tokens.body2,
|
||
background: tokens.badgeTint,
|
||
border: `1px solid ${tokens.line2}`,
|
||
borderRadius: 10,
|
||
padding: "1px 8px",
|
||
}}
|
||
>
|
||
{/* #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. */}
|
||
нет данных
|
||
</span>
|
||
) : (
|
||
<span
|
||
style={{
|
||
fontFamily: tokens.font.mono,
|
||
fontSize: 15,
|
||
fontWeight: 500,
|
||
color: tokens.accent,
|
||
}}
|
||
>
|
||
{data.object.locationCoef}
|
||
</span>
|
||
)}
|
||
<span
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
width: 14,
|
||
height: 14,
|
||
border: `1px solid ${tokens.accent}`,
|
||
borderRadius: "50%",
|
||
fontSize: "8.5px",
|
||
color: tokens.accent,
|
||
fontWeight: 600,
|
||
}}
|
||
aria-hidden="true"
|
||
>
|
||
?
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</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 — 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",
|
||
left: 8,
|
||
top: 8,
|
||
width: 14,
|
||
height: 14,
|
||
borderLeft: `1.5px solid ${tokens.accent}`,
|
||
borderTop: `1.5px solid ${tokens.accent}`,
|
||
pointerEvents: "none",
|
||
}}
|
||
/>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
right: 8,
|
||
top: 8,
|
||
width: 14,
|
||
height: 14,
|
||
borderRight: `1.5px solid ${tokens.accent}`,
|
||
borderTop: `1.5px solid ${tokens.accent}`,
|
||
pointerEvents: "none",
|
||
}}
|
||
/>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
left: 8,
|
||
bottom: 8,
|
||
width: 14,
|
||
height: 14,
|
||
borderLeft: `1.5px solid ${tokens.accent}`,
|
||
borderBottom: `1.5px solid ${tokens.accent}`,
|
||
pointerEvents: "none",
|
||
}}
|
||
/>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
right: 8,
|
||
bottom: 8,
|
||
width: 14,
|
||
height: 14,
|
||
borderRight: `1.5px solid ${tokens.accent}`,
|
||
borderBottom: `1.5px solid ${tokens.accent}`,
|
||
pointerEvents: "none",
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|