"use client"; /** * AddressInput — combobox с автокомплитом адресов в ЕКБ. * Стиль: `.ac-list .ac-item .ac-main .ac-sub` из tradein.html mockup. * * Особенности: * - 200ms debounce * - Открывается при focus и при наборе * - Закрывается по click outside + Esc * - Выбор по mouseDown (предотвращает blur) * - ↑↓ навигация, Enter / Tab выбор * - Поддерживает typo-tolerance через backend (Цвилинга → Цвиллинга) */ import { useEffect, useRef, useState } from "react"; import { apiFetch } from "@/lib/api"; interface SuggestItem { label: string; full_address: string; lat: number; lon: number; kind: string; // ФИАС-идентификатор дома (заполнен только для house-level кандидатов // DaData-тира; иначе null/absent). Пробрасывается наверх через onPickFias. fias_id?: string | null; } interface SuggestResponse { items: SuggestItem[]; } // Pre-filled тестовые адреса — клик подставляет в поле и заодно дёргает suggest. // Все эти адреса дают «medium» или «high» confidence на 2-room 50м² (хорошее покрытие). const PRESET_ADDRESSES: { label: string; full: string; kind: string }[] = [ { label: "ул. Малышева, 84 · Парковый", full: "Екатеринбург, ул. Малышева, 84", kind: "house" }, { label: "ул. Куйбышева, 48 · Парковый", full: "Екатеринбург, ул. Куйбышева, 48", kind: "house" }, { label: "ул. 8 Марта, 50 · Ленинский р-н", full: "Екатеринбург, ул. 8 Марта, 50", kind: "house" }, { label: "ул. Цвиллинга, 58 · Парковый", full: "Екатеринбург, улица Цвиллинга, 58", kind: "house" }, { label: "ул. Белинского, 200 · ЮЗ", full: "Екатеринбург, ул. Белинского, 200", kind: "house" }, { label: "ул. Куйбышева, 30 · центр", full: "Екатеринбург, ул. Куйбышева, 30", kind: "house" }, ]; interface Props { value: string; onChange: (val: string) => void; onPickCoords?: (coords: { lat: number; lon: number } | null) => void; // ФИАС-ключ выбранного дома. Зеркалит поведение onPickCoords: значение при // выборе house-level подсказки, null при ручной правке текста. onPickFias?: (fiasId: string | null) => void; placeholder?: string; inputId?: string; } const KIND_BADGE: Record = { house: { tag: "ДОМ", label: "house" }, street: { tag: "УЛ", label: "street" }, locality: { tag: "Р-Н", label: "locality" }, }; function AreaSubtitle({ item }: { item: SuggestItem }) { const kindBadge = KIND_BADGE[item.kind] ?? { tag: item.kind.toUpperCase(), label: item.kind }; return ( {kindBadge.tag} ); } function presetsToItems(): SuggestItem[] { return PRESET_ADDRESSES.map((p) => ({ label: p.label, full_address: p.full, lat: 0, lon: 0, kind: p.kind, fias_id: null, })); } export function AddressInput({ value, onChange, onPickCoords, onPickFias, placeholder, inputId = "addr" }: Props) { const [open, setOpen] = useState(false); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [highlight, setHighlight] = useState(0); const [showPresets, setShowPresets] = useState(false); const containerRef = useRef(null); const debounceRef = useRef | null>(null); const lastPickedRef = useRef(""); // Debounced fetch на каждое изменение value useEffect(() => { const q = value.trim(); if (q.length < 2) { setItems([]); setOpen(false); return; } setShowPresets(false); // Если value совпадает с последним выбранным — не делаем запрос if (q === lastPickedRef.current) return; if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(async () => { setLoading(true); try { const r = await apiFetch( `/api/v1/geocode/suggest?q=${encodeURIComponent(q)}&limit=8`, ); setItems(r.items); setOpen(r.items.length > 0); setHighlight(0); } catch { setItems([]); setOpen(false); } finally { setLoading(false); } }, 200); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [value]); // Click outside → close useEffect(() => { function onClick(e: MouseEvent) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false); setShowPresets(false); } } document.addEventListener("mousedown", onClick); return () => document.removeEventListener("mousedown", onClick); }, []); function pick(item: SuggestItem) { lastPickedRef.current = item.full_address; onChange(item.full_address); const hasCoords = Number.isFinite(item.lat) && Number.isFinite(item.lon) && item.lat !== 0 && item.lon !== 0; onPickCoords?.(hasCoords ? { lat: item.lat, lon: item.lon } : null); onPickFias?.(item.fias_id ?? null); setOpen(false); setShowPresets(false); setItems([]); } // Активный список для UI (suggest result ИЛИ presets) const activeItems: SuggestItem[] = showPresets ? presetsToItems() : items; const dropdownOpen = (open && items.length > 0) || (showPresets && activeItems.length > 0); function onKeyDown(e: React.KeyboardEvent) { if (!dropdownOpen) { if (e.key === "ArrowDown") { // Открываем dropdown — либо результаты suggest, либо presets if (items.length > 0) setOpen(true); else if (value.trim().length < 2) setShowPresets(true); e.preventDefault(); } return; } if (e.key === "ArrowDown") { e.preventDefault(); setHighlight((h) => Math.min(activeItems.length - 1, h + 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setHighlight((h) => Math.max(0, h - 1)); } else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); pick(activeItems[highlight]); } else if (e.key === "Escape") { setOpen(false); setShowPresets(false); } } return (
{ onChange(e.target.value); lastPickedRef.current = ""; // юзер начал редактировать — снимаем guard onPickCoords?.(null); // ручное редактирование сбрасывает ранее выбранные coords onPickFias?.(null); // ...и ранее выбранный ФИАС (иначе stale перебьёт новый адрес) setShowPresets(false); }} onFocus={() => { // Если есть свежий suggest result — открываем его if (items.length > 0) { setOpen(true); return; } // Если поле пустое — показываем тестовые адреса if (value.trim().length < 2) { setShowPresets(true); setHighlight(0); } }} onKeyDown={onKeyDown} minLength={3} maxLength={500} required /> {loading && ( )}
    {showPresets && (
  • Подсказки · нажмите для подстановки
  • )} {activeItems.map((it, i) => (
  • setHighlight(i)} onMouseDown={(e) => { // mousedown (не click) — иначе input blur'ится до того как обработаем e.preventDefault(); pick(it); }} > {it.label}
  • ))}
); }