fix(tradein/ui): не слать city_hint по умолчанию — только явный выбор или город из адреса (#2576)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
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 1m15s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
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 1m15s
Deep-review R2 на #2580/#2576: предыдущий коммит слал city_hint="Екатеринбург"
даже когда дропдаун не тронут — бэкенд трактует ЛЮБОЙ city_hint как «пользователь
назвал город» (city_specified=True), так что target_city_ambiguous становился
false практически всегда, а необнаруженный житель Нижнего Тагила («Ленина, 1»
без явного упоминания города) молча резолвился бы в Екатеринбург — ровно баг,
который чинил backend, только переехавший из geocoder.py в city-registry.ts.
Вариант A (по рекомендации ревьюера): город реально известен (и поэтому
отправляется в city_hint) ТОЛЬКО когда пользователь явно выбрал его в
дропдауне ИЛИ detectCityInText нашёл совпадение в наборном тексте / выбранной
подсказке. Нетронутый дефолт → city_hint не уходит вовсе (ни в geocode/suggest,
ни в POST /estimate) — тогда backend честно возвращает target_city_ambiguous и
не форсит ЕКБ-bias без запроса.
- Новое состояние `cityConfirmed` (ParamsPanel.tsx) — гейт на отправку,
раздельный от `city` (best-guess для отображения/текста плашки). true после
explicit dropdown pick ИЛИ автодетекта из текста/подсказки; sticky —
мелкая правка адреса без нового совпадения его не сбрасывает.
- До подтверждения дропдаун показывает `UNCONFIRMED_CITY_LABEL`
("Определить автоматически"), не статичное "Екатеринбург" — не выдаёт
внутренний best-guess за подтверждённый пользователем выбор.
- useGeocodeSuggest получает city_hint только при cityConfirmed=true — для
нетронутой формы автокомплит тоже больше не форсит ЕКБ-bias молча, а видит
кандидатов из всей области (в т.ч. Нижний Тагил) — это и есть тот сценарий
из заголовка эпика.
- ЕКБ happy path не усложнён: как только пользователь печатает город в адресе
или (обычный путь) выбирает любую подсказку из автокомплита, detectCityInText
почти всегда находит "Екатеринбург" в full_address (провайдер возвращает
город как часть резолвленного адреса независимо от того, был ли отправлен
hint) — дропдаун сам переключается на "Екатеринбург" и cityConfirmed
становится true без отдельного клика. Требует лишнего действия только
редкий путь "напечатал произвольный адрес без города и нажал Enter, не
выбрав ни одной подсказки".
- city-registry.ts: явный комментарий-ссылка на бэкендовый гэзеттир
`SVERDLOVSK_OBLAST_CITIES` (tradein-mvp/backend/app/services/geocoder.py) —
parity-риск при добавлении нового города остаётся видимым с фронтовой
стороны (backend/тесты не трогаю — другой PR, вне моего scope).
tsc --noEmit / next lint / next build — чисто (только 2 pre-existing warning в
несвязанных файлах, как и в предыдущем коммите).
This commit is contained in:
parent
2779ef5e79
commit
a8d29fa031
2 changed files with 109 additions and 32 deletions
|
|
@ -41,6 +41,7 @@ import { useGeocodeSuggest } from "@/lib/trade-in-api";
|
||||||
import {
|
import {
|
||||||
CITY_LABELS,
|
CITY_LABELS,
|
||||||
DEFAULT_CITY,
|
DEFAULT_CITY,
|
||||||
|
UNCONFIRMED_CITY_LABEL,
|
||||||
detectCityInText,
|
detectCityInText,
|
||||||
} from "@/lib/city-registry";
|
} from "@/lib/city-registry";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -601,14 +602,31 @@ function initRepairLabel(rs: RepairState | undefined): string {
|
||||||
return rs ? REPAIR_RU[rs] : "Не указано";
|
return rs ? REPAIR_RU[rs] : "Не указано";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Город формы (#2576). Restore-by-id / re-estimate: best-effort detect из
|
// Город формы (#2576) — ДВЕ раздельных величины, не одна:
|
||||||
// сохранённого адреса (иначе сброс на Екатеринбург молча увёл бы восстановленный
|
// - `city` (initCityLabel) — best-guess ДЛЯ ОТОБРАЖЕНИЯ/подстановки после
|
||||||
// отчёт другого города обратно на ЕКБ-подсказки). Новая форма без initialValues
|
// подтверждения. Restore-by-id: detect из сохранённого адреса (иначе сброс
|
||||||
// → DEFAULT_CITY ("Екатеринбург") — ЕКБ-сценарий без лишних действий.
|
// на Екатеринбург молча увёл бы восстановленный отчёт другого города назад
|
||||||
|
// на ЕКБ-подсказки); новая форма без initialValues → DEFAULT_CITY.
|
||||||
|
// - `cityConfirmed` (initCityConfirmed, ниже) — ГЕЙТ на отправку city_hint.
|
||||||
|
// Deep-review R2 (#2576): слать `city` в city_hint ВСЕГДА (независимо от того,
|
||||||
|
// подтверждён он или нет) обнуляло весь смысл фичи — бэкенд трактует любой
|
||||||
|
// city_hint как «пользователь назвал город», так что необнаруженный житель
|
||||||
|
// Нижнего Тагила (дропдаун не тронут, город не упомянут в тексте) молча
|
||||||
|
// резолвился бы в Екатеринбург, а target_city_ambiguous был бы false
|
||||||
|
// практически всегда. `city` НИКОГДА не отправляется сам по себе — только
|
||||||
|
// вместе с cityConfirmed=true (см. handleSubmit / useGeocodeSuggest ниже).
|
||||||
function initCityLabel(address: string | undefined): string {
|
function initCityLabel(address: string | undefined): string {
|
||||||
return detectCityInText(address) ?? DEFAULT_CITY.label;
|
return detectCityInText(address) ?? DEFAULT_CITY.label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// true ⇔ город реально известен (restore: обнаружен в сохранённом адресе).
|
||||||
|
// false для свежей формы — «Екатеринбург» в `city` в этот момент ТОЛЬКО
|
||||||
|
// внутренний best-guess (для честного текста плашки), НЕ подтверждённое
|
||||||
|
// значение; дропдаун показывает UNCONFIRMED_CITY_LABEL, а не "Екатеринбург".
|
||||||
|
function initCityConfirmed(address: string | undefined): boolean {
|
||||||
|
return detectCityInText(address) != null;
|
||||||
|
}
|
||||||
|
|
||||||
// РАДИУС options. "Авто" (default) sends no radius_m → the backend keeps its
|
// РАДИУС options. "Авто" (default) sends no radius_m → the backend keeps its
|
||||||
// two-tier default (1000 m primary / 2000 m fallback). A fixed value overrides
|
// two-tier default (1000 m primary / 2000 m fallback). A fixed value overrides
|
||||||
// both ("ищем строго в пределах X м"). Design dropdown was values-only.
|
// both ("ищем строго в пределах X м"). Design dropdown was values-only.
|
||||||
|
|
@ -651,6 +669,12 @@ export default function ParamsPanel({
|
||||||
// control от адреса: меняется через дропдаун ИЛИ автоопределяется из
|
// control от адреса: меняется через дропдаун ИЛИ автоопределяется из
|
||||||
// набранного/выбранного текста (см. handleAddressChange/pickSuggestion).
|
// набранного/выбранного текста (см. handleAddressChange/pickSuggestion).
|
||||||
const [city, setCity] = useState(initCityLabel(initialValues?.address));
|
const [city, setCity] = useState(initCityLabel(initialValues?.address));
|
||||||
|
// Гейт на отправку city_hint (deep-review R2) — см. initCityConfirmed doc
|
||||||
|
// comment. true только после явного выбора в дропдауне или совпадения
|
||||||
|
// detectCityInText; нетронутый дефолт держит его false.
|
||||||
|
const [cityConfirmed, setCityConfirmed] = useState(
|
||||||
|
initCityConfirmed(initialValues?.address),
|
||||||
|
);
|
||||||
const [area, setArea] = useState(
|
const [area, setArea] = useState(
|
||||||
initialValues?.area_m2 != null ? String(initialValues.area_m2) : "",
|
initialValues?.area_m2 != null ? String(initialValues.area_m2) : "",
|
||||||
);
|
);
|
||||||
|
|
@ -712,11 +736,15 @@ export default function ParamsPanel({
|
||||||
// БАЛКОН radiogroup focus targets (roving tabindex).
|
// БАЛКОН radiogroup focus targets (roving tabindex).
|
||||||
const balNoRef = useRef<HTMLButtonElement>(null);
|
const balNoRef = useRef<HTMLButtonElement>(null);
|
||||||
const balYesRef = useRef<HTMLButtonElement>(null);
|
const balYesRef = useRef<HTMLButtonElement>(null);
|
||||||
// #2576: подсказки скопированы по выбранному городу (city_hint) — без него
|
// #2576 / deep-review R2: подсказки скопированы по городу ТОЛЬКО когда он
|
||||||
// геокодер больше не подставляет Екатеринбург молча, а без hint'а
|
// подтверждён (cityConfirmed) — иначе suggest() уходит без city_hint, и
|
||||||
// автокомплит для остальной области смещается на всю область вместо
|
// бэкенд смещает bias на всю область (OBLAST66_VIEWBOX), а не молча на ЕКБ.
|
||||||
// конкретного города. Переключение city обязано рефетчить (city в queryKey).
|
// Это и есть исправление: нетронутая форма для «Ленина 1» теперь видит
|
||||||
const suggest = useGeocodeSuggest(addressQuery, city);
|
// кандидатов из ВСЕЙ области (в т.ч. Нижний Тагил), а не только ЕКБ.
|
||||||
|
// cityHintForApi в зависимостях useGeocodeSuggest — подтверждение города
|
||||||
|
// обязано рефетчить (city/cityConfirmed оба в queryKey через этот аргумент).
|
||||||
|
const cityHintForApi = cityConfirmed ? city : undefined;
|
||||||
|
const suggest = useGeocodeSuggest(addressQuery, cityHintForApi);
|
||||||
|
|
||||||
const handleAddressChange = (v: string) => {
|
const handleAddressChange = (v: string) => {
|
||||||
setAddress(v);
|
setAddress(v);
|
||||||
|
|
@ -728,10 +756,16 @@ export default function ParamsPanel({
|
||||||
setFieldErrors((prev) => ({ ...prev, address: undefined }));
|
setFieldErrors((prev) => ({ ...prev, address: undefined }));
|
||||||
// Autodetect (#2576): если пользователь сам печатает город в тексте
|
// Autodetect (#2576): если пользователь сам печатает город в тексте
|
||||||
// адреса ("Нижний Тагил, Ленина 1"), подхватываем его в дропдаун сразу —
|
// адреса ("Нижний Тагил, Ленина 1"), подхватываем его в дропдаун сразу —
|
||||||
// не ждём выбора подсказки. Не дёргаем setCity без совпадения (город
|
// не ждём выбора подсказки. Найденное совпадение — реальное подтверждение
|
||||||
// остаётся тем, что выбран ранее — обычно ЕКБ по умолчанию).
|
// (пользователь сам его написал), поэтому это ЕДИНСТВЕННОЕ место, где
|
||||||
|
// cityConfirmed включается без явного клика по дропдауну. Отсутствие
|
||||||
|
// совпадения НЕ трогает city/cityConfirmed — предыдущее подтверждение
|
||||||
|
// (если было) остаётся в силе при мелкой правке текста.
|
||||||
const detected = detectCityInText(v);
|
const detected = detectCityInText(v);
|
||||||
if (detected) setCity(detected);
|
if (detected) {
|
||||||
|
setCity(detected);
|
||||||
|
setCityConfirmed(true);
|
||||||
|
}
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = setTimeout(() => setAddressQuery(v), 200);
|
debounceRef.current = setTimeout(() => setAddressQuery(v), 200);
|
||||||
};
|
};
|
||||||
|
|
@ -743,18 +777,27 @@ export default function ParamsPanel({
|
||||||
setFias(s.fias_id ?? null);
|
setFias(s.fias_id ?? null);
|
||||||
setSuggestOpen(false);
|
setSuggestOpen(false);
|
||||||
setAddressActive(-1);
|
setAddressActive(-1);
|
||||||
// Autodetect (#2576): подсказка может называть город, отличный от
|
// Autodetect (#2576): выбор реального гео-резолвленного адреса — сильный
|
||||||
// текущего выбора (bias мягкий, не жёсткий фильтр — см.
|
// сигнал подтверждения (даже сильнее, чем просто набранный текст).
|
||||||
// backend/app/services/geocoder.py::_yandex_bias) — подхватываем его.
|
// full_address обычно содержит город независимо от того, был ли отправлен
|
||||||
|
// city_hint (провайдер возвращает его как часть резолвленного адреса) —
|
||||||
|
// так первый же pick из НЕподтверждённого поиска (bias на всю область)
|
||||||
|
// подтверждает город без отдельного клика по дропдауну.
|
||||||
const detected = detectCityInText(s.full_address);
|
const detected = detectCityInText(s.full_address);
|
||||||
if (detected) setCity(detected);
|
if (detected) {
|
||||||
|
setCity(detected);
|
||||||
|
setCityConfirmed(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Явный выбор города из дропдауна. Сбрасывает ранее выбранные
|
// Явный выбор города из дропдауна — прямое подтверждение пользователем
|
||||||
// координаты/ФИАС — тот же текст адреса под другим городом это уже другая
|
// (deep-review R2: единственный способ гарантированно включить cityConfirmed
|
||||||
// точка (зеркалит handleAddressChange's stale-guard для ручной правки).
|
// независимо от текста адреса). Сбрасывает ранее выбранные координаты/ФИАС —
|
||||||
|
// тот же текст адреса под другим городом это уже другая точка (зеркалит
|
||||||
|
// handleAddressChange's stale-guard для ручной правки).
|
||||||
const selectCity = (v: string) => {
|
const selectCity = (v: string) => {
|
||||||
setCity(v);
|
setCity(v);
|
||||||
|
setCityConfirmed(true);
|
||||||
setOpenDd(null);
|
setOpenDd(null);
|
||||||
setCoords(null);
|
setCoords(null);
|
||||||
setFias(null);
|
setFias(null);
|
||||||
|
|
@ -891,10 +934,12 @@ export default function ParamsPanel({
|
||||||
lon: coords?.lon ?? null,
|
lon: coords?.lon ?? null,
|
||||||
// Детерминированный ключ дома — только из выбранной подсказки (иначе null).
|
// Детерминированный ключ дома — только из выбранной подсказки (иначе null).
|
||||||
target_fias_id: fias,
|
target_fias_id: fias,
|
||||||
// #2576 — город, выбранный/автоопределённый в форме (city-registry.ts).
|
// #2576 / deep-review R2 — город уходит ТОЛЬКО когда подтверждён
|
||||||
// Дефолт "Екатеринбург" воспроизводит прежнее молчаливое поведение
|
// (явный выбор в дропдауне или совпадение в тексте/подсказке); иначе
|
||||||
// геокодера, так что ЕКБ-сценарий не меняется.
|
// city_hint не отправляется вовсе (undefined → ключ выпадает из JSON),
|
||||||
city_hint: city,
|
// и бэкенд честно помечает target_city_ambiguous=true вместо того
|
||||||
|
// чтобы получить непроверенный "Екатеринбург" и промолчать.
|
||||||
|
city_hint: cityConfirmed ? city : undefined,
|
||||||
// "Авто" → null → backend keeps its two-tier default (1000 m primary /
|
// "Авто" → null → backend keeps its two-tier default (1000 m primary /
|
||||||
// 2000 m fallback). A fixed value overrides both. (Не отправлять 500 по
|
// 2000 m fallback). A fixed value overrides both. (Не отправлять 500 по
|
||||||
// умолчанию — это сузило бы поиск аналогов и убрало fallback.)
|
// умолчанию — это сузило бы поиск аналогов и убрало fallback.)
|
||||||
|
|
@ -1462,14 +1507,21 @@ export default function ParamsPanel({
|
||||||
</label>
|
</label>
|
||||||
{/* Город (#2576) — компактный дропдаун рядом с лейблом адреса, не
|
{/* Город (#2576) — компактный дропдаун рядом с лейблом адреса, не
|
||||||
отдельная строка: не отнимает вертикальное место у и так плотной
|
отдельная строка: не отнимает вертикальное место у и так плотной
|
||||||
панели. Дефолт "Екатеринбург" — тот же город, что геокодер
|
панели. Explicit pick сразу подтверждает город (city_hint
|
||||||
раньше подставлял молча, так что ЕКБ-сценарий не требует
|
отправляется дальше). До подтверждения показывает
|
||||||
никакого лишнего действия. Список растёт — см. city-registry.ts. */}
|
UNCONFIRMED_CITY_LABEL, НЕ "Екатеринбург" (deep-review R2) —
|
||||||
|
иначе дропдаун выдавал бы наш внутренний best-guess за
|
||||||
|
подтверждённый пользователем выбор, хотя ничего ещё не
|
||||||
|
отправлялось. Для ЕКБ-сценария это не лишний клик: как только
|
||||||
|
пользователь печатает/выбирает адрес, detectCityInText обычно
|
||||||
|
находит город сам (см. handleAddressChange/pickSuggestion) и
|
||||||
|
лейбл сменится на «Екатеринбург» автоматически. Список городов
|
||||||
|
растёт — см. city-registry.ts. */}
|
||||||
<div style={{ position: "relative", width: 176, flex: "0 0 auto" }}>
|
<div style={{ position: "relative", width: 176, flex: "0 0 auto" }}>
|
||||||
<Dd
|
<Dd
|
||||||
open={openDd === "city"}
|
open={openDd === "city"}
|
||||||
onToggle={() => toggle("city")}
|
onToggle={() => toggle("city")}
|
||||||
value={city}
|
value={cityConfirmed ? city : UNCONFIRMED_CITY_LABEL}
|
||||||
options={CITY_LABELS}
|
options={CITY_LABELS}
|
||||||
onSelect={selectCity}
|
onSelect={selectCity}
|
||||||
mono={false}
|
mono={false}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,25 @@
|
||||||
* #2576, PR #2580: `city_hint` query-параметр + `TradeInEstimateInput.city_hint`
|
* #2576, PR #2580: `city_hint` query-параметр + `TradeInEstimateInput.city_hint`
|
||||||
* + честный `AggregatedEstimate.target_city_ambiguous`).
|
* + честный `AggregatedEstimate.target_city_ambiguous`).
|
||||||
*
|
*
|
||||||
|
* ВАЖНО (deep-review R2 на #2576 UI-часть): `city_hint` — сигнал «пользователь
|
||||||
|
* НАЗВАЛ город», а не «форма его подставила». Если слать дефолт всегда, бэкенд
|
||||||
|
* трактует ЛЮБОЙ запрос как «город указан» → `target_city_ambiguous` перестаёт
|
||||||
|
* быть честным, а необнаруженный Нижний Тагил (дропдаун не тронут, город не
|
||||||
|
* упомянут в тексте) молча резолвится как Екатеринбург — ровно баг, который
|
||||||
|
* чинил backend. Поэтому вызывающий код (ParamsPanel.tsx) обязан слать
|
||||||
|
* `city_hint` ТОЛЬКО когда город реально известен (явный выбор в дропдауне ИЛИ
|
||||||
|
* `detectCityInText` нашёл совпадение) — см. `cityConfirmed` в ParamsPanel.tsx.
|
||||||
|
* Нетронутый дефолт → город НЕ отправляется.
|
||||||
|
*
|
||||||
* Список городов растёт по мере расширения покрытия скрейперами — не хардкодь
|
* Список городов растёт по мере расширения покрытия скрейперами — не хардкодь
|
||||||
* города россыпью по компонентам, добавляй сюда (мирроит паттерн source-registry.ts).
|
* города россыпью по компонентам, добавляй сюда (мирроит паттерн
|
||||||
|
* source-registry.ts). Parity-риск (deep-review R2, дёшево): эти 6 лейблов
|
||||||
|
* должны оставаться подмножеством `SVERDLOVSK_OBLAST_CITIES`
|
||||||
|
* (tradein-mvp/backend/app/services/geocoder.py) — та же кириллица в нижнем
|
||||||
|
* регистре. Расхождение не ломается типами (`city_hint` — свободный текст,
|
||||||
|
* бэкенд его не валидирует по списку), но подсунет город без реального
|
||||||
|
* покрытия скрейпером. Перед добавлением 7-го города — сверить оба списка
|
||||||
|
* вручную (codegen/тест на это пока нет, backend — другой PR).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface OblastCity {
|
export interface OblastCity {
|
||||||
|
|
@ -35,13 +52,21 @@ export const OBLAST_CITIES: readonly OblastCity[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Дефолт формы — Екатеринбург. Держит основной сценарий без дополнительных
|
* Дефолт формы — Екатеринбург (визуальный/best-guess, для текста подсказки и
|
||||||
* действий пользователя: `city_hint` отправляется всегда, но для ЕКБ это тот
|
* подстановки значения ПОСЛЕ подтверждения). НЕ означает "отправляется по
|
||||||
* же текст, что геокодер раньше подставлял молча, так что happy path не
|
* умолчанию" — см. предупреждение в шапке файла: пока город не подтверждён
|
||||||
* меняется ни на пиксель.
|
* (`cityConfirmed` в ParamsPanel.tsx), в запрос ничего не уходит.
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_CITY: OblastCity = OBLAST_CITIES[0];
|
export const DEFAULT_CITY: OblastCity = OBLAST_CITIES[0];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Лейбл дропдауна ДО подтверждения города (свежая форма, город ещё не
|
||||||
|
* выбран/определён). Честнее статичного "Екатеринбург": не выдаёт наш
|
||||||
|
* best-guess за подтверждённый выбор пользователя (deep-review R2 —
|
||||||
|
* `city_hint` не должен отправляться в этом состоянии).
|
||||||
|
*/
|
||||||
|
export const UNCONFIRMED_CITY_LABEL = "Определить автоматически";
|
||||||
|
|
||||||
/** RU-лейблы для дропдауна — тот же формат (string[]), что dropdownOptions.* в v2/fixtures.ts. */
|
/** RU-лейблы для дропдауна — тот же формат (string[]), что dropdownOptions.* в v2/fixtures.ts. */
|
||||||
export const CITY_LABELS: string[] = OBLAST_CITIES.map((c) => c.label);
|
export const CITY_LABELS: string[] = OBLAST_CITIES.map((c) => c.label);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue