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)} + /> + )} +