gendesign/tradein-mvp/frontend/src/components/trade-in/AddressInput.tsx
lekss361 02267d0306 feat: add tradein-mvp subproject (Trade-In Estimator под /trade-in)
- FastAPI backend: PostGIS estimator + 3 scrapers (Avito/Cian/Yandex)
- Next.js 15 frontend: tradein.html mockup design, basePath=/trade-in
- WeasyPrint PDF (Брусника-style 4-page report)
- Address autocomplete с typo-tolerance + 6 EKB presets
- Изолированный docker stack gendesign-tradein (отдельная postgres БД)
- Caddy inline routes: gendsgn.ru/trade-in/* и /trade-in/api/v1/*
- Forgejo Actions: .forgejo/workflows/deploy-tradein.yml (shell-based GHCR login)
- Триггер только по paths: tradein-mvp/** (не пересекается с deploy.yml)
- Образы: ghcr.io/lekss361/gendesign-tradein-{backend,frontend}:latest

Первый запуск на сервере (вручную, один раз):
  - создать /opt/gendesign/tradein-mvp/.env.runtime (postgres pwd, contact email)
  - docker network create gendesign_shared (если нет)
  - docker compose -p gendesign-tradein up -d
  - docker compose -p gendesign exec caddy caddy reload
2026-05-21 00:25:39 +03:00

265 lines
8.9 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;
}
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;
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,
}));
}
export function AddressInput({ value, onChange, 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);
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
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>
);
}