feat(tradein/ui): передавать город в геокодирование + честный признак неопределённости (#2576)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
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 Trade-In / frontend-checks (pull_request) Successful in 1m7s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
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 Trade-In / frontend-checks (pull_request) Successful in 1m7s
Раньше интерфейс город вообще не передавал — backend (#2580) больше не подставляет "Екатеринбург" молча, из-за чего житель Нижнего Тагила, вводя «Ленина, 1», получал бы результат по одноимённой екатеринбургской улице. - Новый справочник src/lib/city-registry.ts (растущий список городов области, сейчас: Екатеринбург, Нижний Тагил, Каменск-Уральский, Первоуральск, Верхняя Пышма, Серов) — DEFAULT_CITY = Екатеринбург, чтобы ЕКБ-сценарий не требовал никаких лишних действий. - ParamsPanel: компактный дропдаун «Город» рядом с лейблом адреса (переиспользует существующий <Dd> HUD-комбобокс) + автоопределение города из набранного текста/выбранной подсказки (detectCityInText, word-boundary safe — не путает "Серов" с "ул. Серова" в ЕКБ). city_hint уходит в geocode/suggest и в POST /trade-in/estimate. - useGeocodeSuggest(query, cityHint, limit) — city_hint в query-параметрах и в queryKey, чтобы смена города рефетчила подсказки. - Честная подсказка в ParamsPanel, когда estimate.target_city_ambiguous===true: спокойный (не danger) текст «Город определён автоматически — результат может относиться к другому населённому пункту области. Если это не {city}, выберите верный город выше и повторите оценку.» — не блокирует форму. - types/trade-in.ts: TradeInEstimateInput.city_hint, AggregatedEstimate.target_city_ambiguous (зеркалит backend PR #2580, ещё не смёржен — codegen не запускался, поля добавлены вручную по контракту схемы). tsc --noEmit / next lint / next build — чисто (только 2 pre-existing warning в несвязанных файлах).
This commit is contained in:
parent
103dc22b8c
commit
2779ef5e79
5 changed files with 218 additions and 20 deletions
|
|
@ -1029,6 +1029,9 @@ export default function TradeInV2Page() {
|
|||
error={apiError}
|
||||
initialValues={initialValues}
|
||||
markers={markers}
|
||||
// #2576 — city_hint contract (backend PR #2580): honest
|
||||
// heads-up when the geocoder picked the city itself.
|
||||
cityAmbiguous={estimate?.target_city_ambiguous ?? false}
|
||||
/>
|
||||
{middleContent}
|
||||
{/* #2275: on mobile ObjectSummary is rendered fluid in the
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ import {
|
|||
type MapMarker,
|
||||
} from "./mappers";
|
||||
import { useGeocodeSuggest } from "@/lib/trade-in-api";
|
||||
import {
|
||||
CITY_LABELS,
|
||||
DEFAULT_CITY,
|
||||
detectCityInText,
|
||||
} from "@/lib/city-registry";
|
||||
import type {
|
||||
GeocodeSuggestion,
|
||||
HouseType,
|
||||
|
|
@ -45,7 +50,7 @@ import type {
|
|||
TradeInEstimateInput,
|
||||
} from "@/types/trade-in";
|
||||
|
||||
type DdKey = "rooms" | "houseType" | "repair" | "radius" | null;
|
||||
type DdKey = "rooms" | "houseType" | "repair" | "radius" | "city" | null;
|
||||
|
||||
// Russian plural picker (one / few / many) for SR announcements.
|
||||
function pluralRu(n: number, one: string, few: string, many: string): string {
|
||||
|
|
@ -523,6 +528,18 @@ const errorText: CSSProperties = {
|
|||
color: tokens.danger,
|
||||
};
|
||||
|
||||
// #2576 — honest, calm (не danger) heads-up когда бэкенд вернул
|
||||
// target_city_ambiguous: true: город определил геокодер, не пользователь.
|
||||
// Тот же тон, что и другие честные caption'ы в этой панели (не паника, не
|
||||
// ошибка формы — просто прозрачность + предложение уточнить).
|
||||
const cityAmbiguousText: CSSProperties = {
|
||||
marginTop: 4,
|
||||
fontSize: 10.5,
|
||||
letterSpacing: 0.3,
|
||||
lineHeight: 1.4,
|
||||
color: tokens.warn,
|
||||
};
|
||||
|
||||
// РАДИУС dropdown panel — mirrors the <Dd> HUD panel (surface.w98 + soft blue
|
||||
// shadow), sized to the narrow radius trigger and dropped just beneath it.
|
||||
const radiusPanel: CSSProperties = {
|
||||
|
|
@ -551,6 +568,12 @@ interface ParamsPanelProps {
|
|||
error?: string | null;
|
||||
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
|
||||
initialValues?: Partial<TradeInEstimateInput>;
|
||||
/**
|
||||
* #2576: true когда последняя оценка вернулась с `target_city_ambiguous` —
|
||||
* геокодер сам угадал город (пользователь его не назвал явно). Рисует
|
||||
* честную некритичную подсказку у поля адреса, не блокирует форму.
|
||||
*/
|
||||
cityAmbiguous?: boolean;
|
||||
/** Analog price pins, projected from the real estimate via mapMarkers() onto
|
||||
* the OLD decorative SVG's fixed 0-100% grid (never a real geo scale — see
|
||||
* mapMarkers() comment in ./mappers.ts). Kept in the prop contract for
|
||||
|
|
@ -578,6 +601,14 @@ function initRepairLabel(rs: RepairState | undefined): string {
|
|||
return rs ? REPAIR_RU[rs] : "Не указано";
|
||||
}
|
||||
|
||||
// Город формы (#2576). Restore-by-id / re-estimate: best-effort detect из
|
||||
// сохранённого адреса (иначе сброс на Екатеринбург молча увёл бы восстановленный
|
||||
// отчёт другого города обратно на ЕКБ-подсказки). Новая форма без initialValues
|
||||
// → DEFAULT_CITY ("Екатеринбург") — ЕКБ-сценарий без лишних действий.
|
||||
function initCityLabel(address: string | undefined): string {
|
||||
return detectCityInText(address) ?? DEFAULT_CITY.label;
|
||||
}
|
||||
|
||||
// РАДИУС options. "Авто" (default) sends no radius_m → the backend keeps its
|
||||
// two-tier default (1000 m primary / 2000 m fallback). A fixed value overrides
|
||||
// both ("ищем строго в пределах X м"). Design dropdown was values-only.
|
||||
|
|
@ -603,6 +634,7 @@ export default function ParamsPanel({
|
|||
hasEstimate = false,
|
||||
error = null,
|
||||
initialValues,
|
||||
cityAmbiguous = false,
|
||||
// markers intentionally not destructured — see the ParamsPanelProps.markers
|
||||
// doc comment: its %-positions belong to the retired decorative SVG grid and
|
||||
// do not correspond to real lat/lon on the Leaflet map below.
|
||||
|
|
@ -615,6 +647,10 @@ export default function ParamsPanel({
|
|||
const radiusListId = useId();
|
||||
const [radiusActive, setRadiusActive] = useState(-1);
|
||||
const [address, setAddress] = useState(initialValues?.address ?? "");
|
||||
// Город формы (#2576 city_hint) — см. initCityLabel doc comment. Independent
|
||||
// control от адреса: меняется через дропдаун ИЛИ автоопределяется из
|
||||
// набранного/выбранного текста (см. handleAddressChange/pickSuggestion).
|
||||
const [city, setCity] = useState(initCityLabel(initialValues?.address));
|
||||
const [area, setArea] = useState(
|
||||
initialValues?.area_m2 != null ? String(initialValues.area_m2) : "",
|
||||
);
|
||||
|
|
@ -676,7 +712,11 @@ export default function ParamsPanel({
|
|||
// БАЛКОН radiogroup focus targets (roving tabindex).
|
||||
const balNoRef = useRef<HTMLButtonElement>(null);
|
||||
const balYesRef = useRef<HTMLButtonElement>(null);
|
||||
const suggest = useGeocodeSuggest(addressQuery);
|
||||
// #2576: подсказки скопированы по выбранному городу (city_hint) — без него
|
||||
// геокодер больше не подставляет Екатеринбург молча, а без hint'а
|
||||
// автокомплит для остальной области смещается на всю область вместо
|
||||
// конкретного города. Переключение city обязано рефетчить (city в queryKey).
|
||||
const suggest = useGeocodeSuggest(addressQuery, city);
|
||||
|
||||
const handleAddressChange = (v: string) => {
|
||||
setAddress(v);
|
||||
|
|
@ -686,6 +726,12 @@ export default function ParamsPanel({
|
|||
setAddressActive(-1); // a new query invalidates the previous highlight
|
||||
if (fieldErrors.address)
|
||||
setFieldErrors((prev) => ({ ...prev, address: undefined }));
|
||||
// Autodetect (#2576): если пользователь сам печатает город в тексте
|
||||
// адреса ("Нижний Тагил, Ленина 1"), подхватываем его в дропдаун сразу —
|
||||
// не ждём выбора подсказки. Не дёргаем setCity без совпадения (город
|
||||
// остаётся тем, что выбран ранее — обычно ЕКБ по умолчанию).
|
||||
const detected = detectCityInText(v);
|
||||
if (detected) setCity(detected);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => setAddressQuery(v), 200);
|
||||
};
|
||||
|
|
@ -697,6 +743,21 @@ export default function ParamsPanel({
|
|||
setFias(s.fias_id ?? null);
|
||||
setSuggestOpen(false);
|
||||
setAddressActive(-1);
|
||||
// Autodetect (#2576): подсказка может называть город, отличный от
|
||||
// текущего выбора (bias мягкий, не жёсткий фильтр — см.
|
||||
// backend/app/services/geocoder.py::_yandex_bias) — подхватываем его.
|
||||
const detected = detectCityInText(s.full_address);
|
||||
if (detected) setCity(detected);
|
||||
};
|
||||
|
||||
// Явный выбор города из дропдауна. Сбрасывает ранее выбранные
|
||||
// координаты/ФИАС — тот же текст адреса под другим городом это уже другая
|
||||
// точка (зеркалит handleAddressChange's stale-guard для ручной правки).
|
||||
const selectCity = (v: string) => {
|
||||
setCity(v);
|
||||
setOpenDd(null);
|
||||
setCoords(null);
|
||||
setFias(null);
|
||||
};
|
||||
|
||||
// Address combobox keyboard (M6). aria-activedescendant pattern: focus STAYS on
|
||||
|
|
@ -830,6 +891,10 @@ export default function ParamsPanel({
|
|||
lon: coords?.lon ?? null,
|
||||
// Детерминированный ключ дома — только из выбранной подсказки (иначе null).
|
||||
target_fias_id: fias,
|
||||
// #2576 — город, выбранный/автоопределённый в форме (city-registry.ts).
|
||||
// Дефолт "Екатеринбург" воспроизводит прежнее молчаливое поведение
|
||||
// геокодера, так что ЕКБ-сценарий не меняется.
|
||||
city_hint: city,
|
||||
// "Авто" → null → backend keeps its two-tier default (1000 m primary /
|
||||
// 2000 m fallback). A fixed value overrides both. (Не отправлять 500 по
|
||||
// умолчанию — это сузило бы поиск аналогов и убрало fallback.)
|
||||
|
|
@ -1384,12 +1449,38 @@ export default function ParamsPanel({
|
|||
}}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="pp-address"
|
||||
style={{ ...hintLabel, display: "block" }}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
АДРЕС (YANDEX / NOMINATIM)
|
||||
</label>
|
||||
<label htmlFor="pp-address" style={hintLabel}>
|
||||
АДРЕС (YANDEX / NOMINATIM)
|
||||
</label>
|
||||
{/* Город (#2576) — компактный дропдаун рядом с лейблом адреса, не
|
||||
отдельная строка: не отнимает вертикальное место у и так плотной
|
||||
панели. Дефолт "Екатеринбург" — тот же город, что геокодер
|
||||
раньше подставлял молча, так что ЕКБ-сценарий не требует
|
||||
никакого лишнего действия. Список растёт — см. city-registry.ts. */}
|
||||
<div style={{ position: "relative", width: 176, flex: "0 0 auto" }}>
|
||||
<Dd
|
||||
open={openDd === "city"}
|
||||
onToggle={() => toggle("city")}
|
||||
value={city}
|
||||
options={CITY_LABELS}
|
||||
onSelect={selectCity}
|
||||
mono={false}
|
||||
triggerFontSize={11}
|
||||
optionFontSize={11.5}
|
||||
triggerHeight={22}
|
||||
variant="dashed"
|
||||
ariaLabel="Город"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ position: "relative" }}>
|
||||
{/* Address autocomplete (geocode suggest, ЕКБ viewbox). Typed text
|
||||
is debounced into `addressQuery`; the dropdown below mirrors the
|
||||
|
|
@ -1508,6 +1599,16 @@ export default function ParamsPanel({
|
|||
{fieldErrors.address}
|
||||
</div>
|
||||
)}
|
||||
{/* #2576 — честная, не блокирующая подсказка: последняя оценка
|
||||
вернулась с target_city_ambiguous, город определил геокодер
|
||||
сам. role="status" (не alert) — это информация, не ошибка. */}
|
||||
{cityAmbiguous && !fieldErrors.address && (
|
||||
<div style={cityAmbiguousText} role="status">
|
||||
Город определён автоматически — результат может относиться к
|
||||
другому населённому пункту области. Если это не {city},
|
||||
выберите верный город выше и повторите оценку.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
68
tradein-mvp/frontend/src/lib/city-registry.ts
Normal file
68
tradein-mvp/frontend/src/lib/city-registry.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* city-registry — единый источник правды по городам Свердловской области,
|
||||
* поддержанным сбором «Меры» (#2576 этап B: расширение на область).
|
||||
*
|
||||
* Раньше геокодер молча подставлял "Екатеринбург, " в любой запрос без явного
|
||||
* города в тексте адреса — житель Нижнего Тагила, вводя «Ленина, 1», получал
|
||||
* уверенно неверную цену по одноимённой екатеринбургской улице (backend fix
|
||||
* #2576, PR #2580: `city_hint` query-параметр + `TradeInEstimateInput.city_hint`
|
||||
* + честный `AggregatedEstimate.target_city_ambiguous`).
|
||||
*
|
||||
* Список городов растёт по мере расширения покрытия скрейперами — не хардкодь
|
||||
* города россыпью по компонентам, добавляй сюда (мирроит паттерн source-registry.ts).
|
||||
*/
|
||||
|
||||
export interface OblastCity {
|
||||
/** Стабильный id (React key / будущая аналитика) — НЕ отправляется бэкенду. */
|
||||
readonly id: string;
|
||||
/**
|
||||
* Человекочитаемое имя — ровно то, что отправляется бэкенду как `city_hint`
|
||||
* (backend/app/services/geocoder.py::_resolve_city_for_geocode принимает
|
||||
* произвольный текст города, не enum/slug).
|
||||
*/
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
// ЕКБ первым — это DEFAULT_CITY (см. ниже) и основной трафик; порядок
|
||||
// остальных — по охвату сбора, не алфавитный.
|
||||
export const OBLAST_CITIES: readonly OblastCity[] = [
|
||||
{ id: "ekaterinburg", label: "Екатеринбург" },
|
||||
{ id: "nizhny_tagil", label: "Нижний Тагил" },
|
||||
{ id: "kamensk_uralsky", label: "Каменск-Уральский" },
|
||||
{ id: "pervouralsk", label: "Первоуральск" },
|
||||
{ id: "verkhnyaya_pyshma", label: "Верхняя Пышма" },
|
||||
{ id: "serov", label: "Серов" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Дефолт формы — Екатеринбург. Держит основной сценарий без дополнительных
|
||||
* действий пользователя: `city_hint` отправляется всегда, но для ЕКБ это тот
|
||||
* же текст, что геокодер раньше подставлял молча, так что happy path не
|
||||
* меняется ни на пиксель.
|
||||
*/
|
||||
export const DEFAULT_CITY: OblastCity = OBLAST_CITIES[0];
|
||||
|
||||
/** RU-лейблы для дропдауна — тот же формат (string[]), что dropdownOptions.* в v2/fixtures.ts. */
|
||||
export const CITY_LABELS: string[] = OBLAST_CITIES.map((c) => c.label);
|
||||
|
||||
/**
|
||||
* Best-effort поиск известного города области как ЦЕЛОГО токена в
|
||||
* произвольном тексте (набранный адрес / `full_address` выбранной подсказки)
|
||||
* — автоопределение города наравне с явным выбором из дропдауна.
|
||||
*
|
||||
* Токен обязан быть delimited началом/концом строки, запятой или пробелом —
|
||||
* иначе "Серов" ложно сматчился бы внутри "ул. Серова" (реальная
|
||||
* екатеринбургская улица, названная в честь лётчика Серова) и увёл бы выбор
|
||||
* города от Екатеринбурга на Серов. JS `\b` не работает вокруг кириллицы
|
||||
* (только ASCII `\w`), поэтому границы токена — явные символьные классы, как
|
||||
* в v2/mappers.ts::STREET_RE.
|
||||
*/
|
||||
export function detectCityInText(text: string | null | undefined): string | undefined {
|
||||
if (!text) return undefined;
|
||||
return OBLAST_CITIES.find((c) => cityTokenRe(c.label).test(text))?.label;
|
||||
}
|
||||
|
||||
function cityTokenRe(label: string): RegExp {
|
||||
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`(^|[,\\s])${escaped}([,\\s]|$)`, "i");
|
||||
}
|
||||
|
|
@ -257,22 +257,35 @@ export function useEstimateHistory(limit = 50) {
|
|||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/geocode/suggest?q=&limit=
|
||||
* Автокомплит адресов в пределах ЕКБ для поля адреса (ParamsPanel). Debounce-
|
||||
* friendly: вызывающий компонент дебаунсит строку query, хук кешируется по
|
||||
* queryKey; `enabled` срабатывает только начиная с 3 символов (бэкенд min 2,
|
||||
* берём 3 чтобы не дёргать на 1-2 символа). `select` разворачивает обёртку
|
||||
* {items} → GeocodeSuggestion[]; keepPreviousData убирает мерцание списка между
|
||||
* последовательными запросами.
|
||||
* GET /api/v1/geocode/suggest?q=&limit=&city_hint=
|
||||
* Автокомплит адресов в Свердловской области для поля адреса (ParamsPanel).
|
||||
* Debounce-friendly: вызывающий компонент дебаунсит строку query, хук
|
||||
* кешируется по queryKey; `enabled` срабатывает только начиная с 3 символов
|
||||
* (бэкенд min 2, берём 3 чтобы не дёргать на 1-2 символа). `select`
|
||||
* разворачивает обёртку {items} → GeocodeSuggestion[]; keepPreviousData
|
||||
* убирает мерцание списка между последовательными запросами.
|
||||
*
|
||||
* `cityHint` — #2576 (backend PR #2580): без него геокодер больше НЕ
|
||||
* подставляет "Екатеринбург" молча (см. src/lib/city-registry.ts — форма
|
||||
* держит дефолт "Екатеринбург", так что ЕКБ-сценарий не деградирует). Часть
|
||||
* queryKey — переключение города в форме обязано рефетчить подсказки.
|
||||
*/
|
||||
export function useGeocodeSuggest(query: string, limit = 8) {
|
||||
export function useGeocodeSuggest(
|
||||
query: string,
|
||||
cityHint?: string | null,
|
||||
limit = 8,
|
||||
) {
|
||||
const q = query.trim();
|
||||
const hint = (cityHint ?? "").trim();
|
||||
return useQuery<GeocodeSuggestResponse, Error, GeocodeSuggestion[]>({
|
||||
queryKey: ["trade-in", "geocode-suggest", q, limit],
|
||||
queryFn: () =>
|
||||
apiFetch<GeocodeSuggestResponse>(
|
||||
`${GEOCODE_BASE}/suggest?q=${encodeURIComponent(q)}&limit=${limit}`,
|
||||
),
|
||||
queryKey: ["trade-in", "geocode-suggest", q, hint, limit],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ q, limit: String(limit) });
|
||||
if (hint) params.set("city_hint", hint);
|
||||
return apiFetch<GeocodeSuggestResponse>(
|
||||
`${GEOCODE_BASE}/suggest?${params.toString()}`,
|
||||
);
|
||||
},
|
||||
select: (r) => r.items,
|
||||
enabled: q.length >= 3,
|
||||
staleTime: 5 * 60_000,
|
||||
|
|
|
|||
|
|
@ -75,6 +75,13 @@ export interface TradeInEstimateInput {
|
|||
// до геокодинга строки. Отправляется ТОЛЬКО когда адрес выбран из подсказки;
|
||||
// сбрасывается в null при ручной правке адреса или сдвиге пина на карте.
|
||||
target_fias_id?: string | null;
|
||||
// #2576 (backend PR #2580): город, выбранный пользователем в форме (см.
|
||||
// src/lib/city-registry.ts) — без него геокодер больше НЕ подставляет
|
||||
// "Екатеринбург" молча (житель другого города области раньше получал
|
||||
// уверенно неверную цену по одноимённой екатеринбургской улице). Дефолт формы
|
||||
// уже "Екатеринбург" (city-registry.ts::DEFAULT_CITY), так что ЕКБ-сценарий
|
||||
// отправляет ровно то, что геокодер раньше подставлял сам.
|
||||
city_hint?: string | null;
|
||||
}
|
||||
|
||||
export interface AnalogLot {
|
||||
|
|
@ -169,6 +176,12 @@ export interface AggregatedEstimate {
|
|||
target_address: string | null;
|
||||
target_lat: number | null;
|
||||
target_lon: number | null;
|
||||
// #2576 (backend PR #2580): true если ни адрес, ни `TradeInEstimateInput
|
||||
// .city_hint` не называли город явно — итоговый город (и, соответственно,
|
||||
// набор аналогов/цена) определил геокодер-провайдер, а не пользователь.
|
||||
// Ephemeral (не персистится в БД, только для текущего ответа) — optional,
|
||||
// т.к. оценки, посчитанные до деплоя бэкенда, поле не содержат.
|
||||
target_city_ambiguous?: boolean;
|
||||
sources_used: string[]; // ['avito', 'cian', 'rosreestr']
|
||||
// #2043 (BE-1): достоверность выборки — реальный коэффициент вариации ₽/м² (std/mean),
|
||||
// счётчики аналогов по источнику, момент создания оценки. Все optional: старые
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue