From d456e4fecbaafd321a1c2977ae7d099cf82aeb00 Mon Sep 17 00:00:00 2001 From: TradeIn Deploy Date: Fri, 22 May 2026 12:09:35 +0500 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=B0=20=D0=BD=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=B5=20=D0=95=D0=9A=D0=91=20(#415)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кнопка справа от поля адреса → модалка с картой Екатеринбурга. Клик по дому → 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 --- tradein-mvp/backend/app/api/v1/geocode.py | 17 +- tradein-mvp/backend/app/services/geocoder.py | 30 +++ .../src/components/trade-in/EstimateForm.tsx | 55 +++++- .../src/components/trade-in/MapPicker.tsx | 187 ++++++++++++++++++ 4 files changed, 278 insertions(+), 11 deletions(-) create mode 100644 tradein-mvp/frontend/src/components/trade-in/MapPicker.tsx diff --git a/tradein-mvp/backend/app/api/v1/geocode.py b/tradein-mvp/backend/app/api/v1/geocode.py index 59fbdb9b..0324b0b1 100644 --- a/tradein-mvp/backend/app/api/v1/geocode.py +++ b/tradein-mvp/backend/app/api/v1/geocode.py @@ -10,7 +10,7 @@ from pydantic import BaseModel from sqlalchemy.orm import Session 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__) @@ -68,3 +68,18 @@ async def suggest_addresses( lat=s.lat, lon=s.lon, kind=s.kind, ) 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} diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 2c58e989..8cd5d357 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -533,3 +533,33 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None: logger.exception("nominatim geocoder failed") 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") diff --git a/tradein-mvp/frontend/src/components/trade-in/EstimateForm.tsx b/tradein-mvp/frontend/src/components/trade-in/EstimateForm.tsx index 384f6f6a..2c37c266 100644 --- a/tradein-mvp/frontend/src/components/trade-in/EstimateForm.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/EstimateForm.tsx @@ -8,6 +8,7 @@ import type { TradeInEstimateInput, } from "@/types/trade-in"; import { AddressInput } from "@/components/trade-in/AddressInput"; +import { MapPicker } from "@/components/trade-in/MapPicker"; interface Props { onSubmit: (input: TradeInEstimateInput) => void; @@ -87,6 +88,7 @@ function toPayload(f: FormState): TradeInEstimateInput { export function EstimateForm({ onSubmit, isPending, error }: Props) { const [form, setForm] = useState(INITIAL); const [touched, setTouched] = useState(false); + const [mapOpen, setMapOpen] = useState(false); const valError = validate(form); const canSubmit = !valError && !isPending; @@ -124,16 +126,42 @@ export function EstimateForm({ onSubmit, isPending, error }: Props) { Адрес Yandex / Nominatim обязательно -
- - - - - setForm((s) => ({ ...s, address: val }))} - placeholder="ул. Малышева, 30 · Цвиллинга, 58 · Куйбышева, 48…" - /> +
+
+ + + + + setForm((s) => ({ ...s, address: val }))} + placeholder="ул. Малышева, 30 · Куйбышева, 48…" + /> +
+
@@ -408,6 +436,13 @@ export function EstimateForm({ onSubmit, isPending, error }: Props) { + {mapOpen && ( + setForm((s) => ({ ...s, address: addr }))} + onClose={() => setMapOpen(false)} + /> + )} +