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
This commit is contained in:
parent
c25ccee58c
commit
d456e4fecb
4 changed files with 278 additions and 11 deletions
|
|
@ -10,7 +10,7 @@ from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.services.geocoder import GeocodeResult, geocode, suggest
|
from app.services.geocoder import GeocodeResult, geocode, reverse_geocode, suggest
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -68,3 +68,18 @@ async def suggest_addresses(
|
||||||
lat=s.lat, lon=s.lon, kind=s.kind,
|
lat=s.lat, lon=s.lon, kind=s.kind,
|
||||||
) for s in items
|
) for s in items
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reverse")
|
||||||
|
async def reverse(
|
||||||
|
lat: Annotated[float, Query(ge=-90, le=90)],
|
||||||
|
lon: Annotated[float, Query(ge=-180, le=180)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Обратный геокодинг — координаты с карты → адрес (map-picker).
|
||||||
|
|
||||||
|
Пример: /api/v1/geocode/reverse?lat=56.8389&lon=60.6057
|
||||||
|
"""
|
||||||
|
address = await reverse_geocode(lat, lon)
|
||||||
|
if address is None:
|
||||||
|
raise HTTPException(status_code=404, detail="address not found for coordinates")
|
||||||
|
return {"address": address, "lat": lat, "lon": lon}
|
||||||
|
|
|
||||||
|
|
@ -533,3 +533,33 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
||||||
logger.exception("nominatim geocoder failed")
|
logger.exception("nominatim geocoder failed")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Reverse: координаты → адрес (для map-picker'а) ──────────────────────────
|
||||||
|
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
||||||
|
async def reverse_geocode(lat: float, lon: float) -> str | None:
|
||||||
|
"""Обратный геокодинг: координаты → адрес (Nominatim /reverse).
|
||||||
|
|
||||||
|
Клик по дому на карте ЕКБ → адрес подставляется в форму оценки.
|
||||||
|
"""
|
||||||
|
headers = {
|
||||||
|
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Accept-Language": "ru,en;q=0.8",
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
|
||||||
|
response = await client.get(
|
||||||
|
"https://nominatim.openstreetmap.org/reverse",
|
||||||
|
params={
|
||||||
|
"lat": str(lat),
|
||||||
|
"lon": str(lon),
|
||||||
|
"format": "json",
|
||||||
|
"addressdetails": "1",
|
||||||
|
"zoom": "18",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
if not isinstance(data, dict) or "error" in data:
|
||||||
|
return None
|
||||||
|
return data.get("display_name")
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import type {
|
||||||
TradeInEstimateInput,
|
TradeInEstimateInput,
|
||||||
} from "@/types/trade-in";
|
} from "@/types/trade-in";
|
||||||
import { AddressInput } from "@/components/trade-in/AddressInput";
|
import { AddressInput } from "@/components/trade-in/AddressInput";
|
||||||
|
import { MapPicker } from "@/components/trade-in/MapPicker";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSubmit: (input: TradeInEstimateInput) => void;
|
onSubmit: (input: TradeInEstimateInput) => void;
|
||||||
|
|
@ -87,6 +88,7 @@ function toPayload(f: FormState): TradeInEstimateInput {
|
||||||
export function EstimateForm({ onSubmit, isPending, error }: Props) {
|
export function EstimateForm({ onSubmit, isPending, error }: Props) {
|
||||||
const [form, setForm] = useState<FormState>(INITIAL);
|
const [form, setForm] = useState<FormState>(INITIAL);
|
||||||
const [touched, setTouched] = useState(false);
|
const [touched, setTouched] = useState(false);
|
||||||
|
const [mapOpen, setMapOpen] = useState(false);
|
||||||
const valError = validate(form);
|
const valError = validate(form);
|
||||||
const canSubmit = !valError && !isPending;
|
const canSubmit = !valError && !isPending;
|
||||||
|
|
||||||
|
|
@ -124,16 +126,42 @@ export function EstimateForm({ onSubmit, isPending, error }: Props) {
|
||||||
Адрес <span className="hint">Yandex / Nominatim</span>
|
Адрес <span className="hint">Yandex / Nominatim</span>
|
||||||
<span className="req">обязательно</span>
|
<span className="req">обязательно</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="control-with-icon">
|
<div style={{ display: "flex", gap: 8, alignItems: "stretch" }}>
|
||||||
<svg className="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<div className="control-with-icon" style={{ flex: 1, minWidth: 0 }}>
|
||||||
<path d="M21 10c0 7-9 13-9 13S3 17 3 10a9 9 0 0 1 18 0z" />
|
<svg className="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<circle cx="12" cy="10" r="3" />
|
<path d="M21 10c0 7-9 13-9 13S3 17 3 10a9 9 0 0 1 18 0z" />
|
||||||
</svg>
|
<circle cx="12" cy="10" r="3" />
|
||||||
<AddressInput
|
</svg>
|
||||||
value={form.address}
|
<AddressInput
|
||||||
onChange={(val) => setForm((s) => ({ ...s, address: val }))}
|
value={form.address}
|
||||||
placeholder="ул. Малышева, 30 · Цвиллинга, 58 · Куйбышева, 48…"
|
onChange={(val) => setForm((s) => ({ ...s, address: val }))}
|
||||||
/>
|
placeholder="ул. Малышева, 30 · Куйбышева, 48…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMapOpen(true)}
|
||||||
|
title="Выбрать на карте ЕКБ"
|
||||||
|
aria-label="Выбрать на карте"
|
||||||
|
style={{
|
||||||
|
flex: "0 0 auto",
|
||||||
|
width: 44,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
border: "1px solid var(--border, #d4d4d4)",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "var(--bg, #fff)",
|
||||||
|
color: "var(--accent)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6" />
|
||||||
|
<line x1="8" y1="2" x2="8" y2="18" />
|
||||||
|
<line x1="16" y1="6" x2="16" y2="22" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -408,6 +436,13 @@ export function EstimateForm({ onSubmit, isPending, error }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{mapOpen && (
|
||||||
|
<MapPicker
|
||||||
|
onPick={(addr) => setForm((s) => ({ ...s, address: addr }))}
|
||||||
|
onClose={() => setMapOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes ti-spin {
|
@keyframes ti-spin {
|
||||||
from { transform: rotate(0deg); }
|
from { transform: rotate(0deg); }
|
||||||
|
|
|
||||||
187
tradein-mvp/frontend/src/components/trade-in/MapPicker.tsx
Normal file
187
tradein-mvp/frontend/src/components/trade-in/MapPicker.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue