gendesign/tradein-mvp/frontend/src/components/trade-in/MapPicker.tsx
TradeIn Deploy d456e4fecb feat(tradein): выбор адреса на карте ЕКБ (#415)
Кнопка справа от поля адреса → модалка с картой Екатеринбурга.
Клик по дому → reverse-геокодинг → адрес подставляется в форму.

- geocoder.reverse_geocode + GET /api/v1/geocode/reverse — Nominatim
  /reverse (координаты → адрес).
- MapPicker.tsx — модалка с Leaflet + OSM-тайлами (Leaflet с CDN,
  без npm-зависимости), circleMarker по клику.
- EstimateForm — кнопка-карта справа от AddressInput.

Карта на OSM (без API-ключа). При появлении ключа Yandex (#402)
можно перейти на Я.Карты.

Closes #415
2026-05-22 12:09:35 +05:00

187 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/* Выбор адреса на карте ЕКБ (Leaflet + OSM). Клик по дому → reverse-геокодинг. */
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet */
import { useEffect, useRef, useState } from "react";
import { API_BASE_URL } from "@/lib/api";
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 EKB_CENTER: [number, number] = [56.8389, 60.6057];
/** Подгружает Leaflet с CDN один раз, резолвит window.L. */
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.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.setAttribute("data-leaflet", "1");
script.onload = () => resolve(w.L);
script.onerror = () => reject(new Error("leaflet load failed"));
document.body.appendChild(script);
});
}
interface Props {
onPick: (address: string) => void;
onClose: () => void;
}
export function MapPicker({ onPick, onClose }: Props) {
const mapRef = useRef<HTMLDivElement>(null);
const [address, setAddress] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [mapError, setMapError] = useState(false);
useEffect(() => {
let map: any = null;
let marker: any = null;
let cancelled = false;
loadLeaflet()
.then((L) => {
if (cancelled || !mapRef.current) return;
map = L.map(mapRef.current).setView(EKB_CENTER, 12);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© OpenStreetMap",
maxZoom: 19,
}).addTo(map);
setTimeout(() => map && map.invalidateSize(), 120);
map.on("click", (e: any) => {
const lat = e.latlng.lat as number;
const lon = e.latlng.lng as number;
if (marker) marker.setLatLng([lat, lon]);
else
marker = L.circleMarker([lat, lon], {
radius: 8,
color: "#1d4ed8",
weight: 2,
fillColor: "#1d4ed8",
fillOpacity: 0.45,
}).addTo(map);
setAddress(null);
setLoading(true);
fetch(`${API_BASE_URL}/api/v1/geocode/reverse?lat=${lat}&lon=${lon}`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => setAddress((d?.address as string) ?? null))
.catch(() => setAddress(null))
.finally(() => setLoading(false));
});
})
.catch(() => setMapError(true));
return () => {
cancelled = true;
if (map) map.remove();
};
}, []);
return (
<div
onClick={onClose}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,.5)",
zIndex: 1000,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 20,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: "#fff",
borderRadius: 12,
width: "min(900px, 100%)",
maxHeight: "92vh",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px 16px",
borderBottom: "1px solid #e5e5e5",
}}
>
<b>Выбор адреса на карте ЕКБ</b>
<button
type="button"
onClick={onClose}
aria-label="Закрыть"
style={{ border: "none", background: "none", fontSize: 22, cursor: "pointer", lineHeight: 1 }}
>
×
</button>
</div>
{mapError ? (
<div style={{ padding: 48, textAlign: "center", color: "#c0392b" }}>
Не удалось загрузить карту. Проверьте интернет-соединение.
</div>
) : (
<div ref={mapRef} style={{ height: 460, width: "100%", background: "#eef1f4" }} />
)}
<div
style={{
padding: "12px 16px",
borderTop: "1px solid #e5e5e5",
display: "flex",
gap: 12,
alignItems: "center",
}}
>
<div style={{ flex: 1, fontSize: 14, color: address ? "#111" : "#888" }}>
{loading
? "Определяем адрес…"
: address
? address
: "Кликните по дому на карте"}
</div>
<button
type="button"
onClick={() => {
if (address) {
onPick(address);
onClose();
}
}}
disabled={!address || loading}
className="btn btn-primary"
style={{ opacity: address && !loading ? 1 : 0.5, whiteSpace: "nowrap" }}
>
Подставить адрес
</button>
</div>
</div>
</div>
);
}