gendesign/tradein-mvp/frontend/src/components/trade-in/AddressInput.tsx
bot-backend 333a36f049
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 7s
feat(tradein/frontend): pass suggest fias_id as target_fias_id in estimate request
2026-07-02 20:56:50 +03:00

282 lines
10 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";
/**
* 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<string, { tag: string; label: string }> = {
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 (
<span className="ac-sub" style={{ display: "flex", gap: 6, alignItems: "center" }}>
<span
style={{
padding: "1px 5px",
background: "var(--surface-3)",
color: "var(--muted)",
borderRadius: 3,
fontSize: 9,
fontWeight: 600,
letterSpacing: "0.05em",
}}
>
{kindBadge.tag}
</span>
</span>
);
}
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<SuggestItem[]>([]);
const [loading, setLoading] = useState(false);
const [highlight, setHighlight] = useState(0);
const [showPresets, setShowPresets] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastPickedRef = useRef<string>("");
// 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<SuggestResponse>(
`/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<HTMLInputElement>) {
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 (
<div ref={containerRef} style={{ position: "relative", width: "100%" }}>
<input
id={inputId}
type="text"
className="control"
placeholder={placeholder}
value={value}
autoComplete="off"
onChange={(e) => {
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 && (
<span
aria-hidden="true"
style={{
position: "absolute",
right: 10,
top: "50%",
transform: "translateY(-50%)",
fontSize: 10,
color: "var(--muted-2)",
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" style={{ animation: "ti-spin 1s linear infinite" }}>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
</span>
)}
<ul className={`ac-list ${dropdownOpen ? "is-open" : ""}`} role="listbox">
{showPresets && (
<li
style={{
padding: "6px 10px",
fontSize: 10,
color: "var(--muted-2)",
fontWeight: 600,
letterSpacing: "0.08em",
textTransform: "uppercase",
borderBottom: "1px solid var(--border)",
cursor: "default",
}}
>
Подсказки · нажмите для подстановки
</li>
)}
{activeItems.map((it, i) => (
<li
key={`${it.full_address}-${i}`}
className={`ac-item ${i === highlight ? "is-focus" : ""}`}
role="option"
aria-selected={i === highlight}
onMouseEnter={() => setHighlight(i)}
onMouseDown={(e) => {
// mousedown (не click) — иначе input blur'ится до того как обработаем
e.preventDefault();
pick(it);
}}
>
<span className="ac-main">{it.label}</span>
<AreaSubtitle item={it} />
</li>
))}
</ul>
</div>
);
}