Merge pull request 'feat(tradein/ui): передавать город в геокодирование + честный признак неопределённости (#2576)' (#2584) from feat/tradein-ui-city-hint into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m28s
Deploy Trade-In / build-frontend (push) Successful in 2m40s
Deploy Trade-In / build-backend (push) Successful in 1m17s
Deploy Trade-In / deploy (push) Successful in 6m57s
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m28s
Deploy Trade-In / build-frontend (push) Successful in 2m40s
Deploy Trade-In / build-backend (push) Successful in 1m17s
Deploy Trade-In / deploy (push) Successful in 6m57s
This commit is contained in:
commit
a7fbd348e0
5 changed files with 312 additions and 20 deletions
|
|
@ -1029,6 +1029,9 @@ export default function TradeInV2Page() {
|
||||||
error={apiError}
|
error={apiError}
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
markers={markers}
|
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}
|
{middleContent}
|
||||||
{/* #2275: on mobile ObjectSummary is rendered fluid in the
|
{/* #2275: on mobile ObjectSummary is rendered fluid in the
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,12 @@ import {
|
||||||
type MapMarker,
|
type MapMarker,
|
||||||
} from "./mappers";
|
} from "./mappers";
|
||||||
import { useGeocodeSuggest } from "@/lib/trade-in-api";
|
import { useGeocodeSuggest } from "@/lib/trade-in-api";
|
||||||
|
import {
|
||||||
|
CITY_LABELS,
|
||||||
|
DEFAULT_CITY,
|
||||||
|
UNCONFIRMED_CITY_LABEL,
|
||||||
|
detectCityInText,
|
||||||
|
} from "@/lib/city-registry";
|
||||||
import type {
|
import type {
|
||||||
GeocodeSuggestion,
|
GeocodeSuggestion,
|
||||||
HouseType,
|
HouseType,
|
||||||
|
|
@ -45,7 +51,7 @@ import type {
|
||||||
TradeInEstimateInput,
|
TradeInEstimateInput,
|
||||||
} from "@/types/trade-in";
|
} 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.
|
// Russian plural picker (one / few / many) for SR announcements.
|
||||||
function pluralRu(n: number, one: string, few: string, many: string): string {
|
function pluralRu(n: number, one: string, few: string, many: string): string {
|
||||||
|
|
@ -523,6 +529,18 @@ const errorText: CSSProperties = {
|
||||||
color: tokens.danger,
|
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
|
// РАДИУС dropdown panel — mirrors the <Dd> HUD panel (surface.w98 + soft blue
|
||||||
// shadow), sized to the narrow radius trigger and dropped just beneath it.
|
// shadow), sized to the narrow radius trigger and dropped just beneath it.
|
||||||
const radiusPanel: CSSProperties = {
|
const radiusPanel: CSSProperties = {
|
||||||
|
|
@ -551,6 +569,12 @@ interface ParamsPanelProps {
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
|
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
|
||||||
initialValues?: Partial<TradeInEstimateInput>;
|
initialValues?: Partial<TradeInEstimateInput>;
|
||||||
|
/**
|
||||||
|
* #2576: true когда последняя оценка вернулась с `target_city_ambiguous` —
|
||||||
|
* геокодер сам угадал город (пользователь его не назвал явно). Рисует
|
||||||
|
* честную некритичную подсказку у поля адреса, не блокирует форму.
|
||||||
|
*/
|
||||||
|
cityAmbiguous?: boolean;
|
||||||
/** Analog price pins, projected from the real estimate via mapMarkers() onto
|
/** 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
|
* 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
|
* mapMarkers() comment in ./mappers.ts). Kept in the prop contract for
|
||||||
|
|
@ -578,6 +602,31 @@ function initRepairLabel(rs: RepairState | undefined): string {
|
||||||
return rs ? REPAIR_RU[rs] : "Не указано";
|
return rs ? REPAIR_RU[rs] : "Не указано";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Город формы (#2576) — ДВЕ раздельных величины, не одна:
|
||||||
|
// - `city` (initCityLabel) — best-guess ДЛЯ ОТОБРАЖЕНИЯ/подстановки после
|
||||||
|
// подтверждения. Restore-by-id: detect из сохранённого адреса (иначе сброс
|
||||||
|
// на Екатеринбург молча увёл бы восстановленный отчёт другого города назад
|
||||||
|
// на ЕКБ-подсказки); новая форма без 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 {
|
||||||
|
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.
|
||||||
|
|
@ -603,6 +652,7 @@ export default function ParamsPanel({
|
||||||
hasEstimate = false,
|
hasEstimate = false,
|
||||||
error = null,
|
error = null,
|
||||||
initialValues,
|
initialValues,
|
||||||
|
cityAmbiguous = false,
|
||||||
// markers intentionally not destructured — see the ParamsPanelProps.markers
|
// markers intentionally not destructured — see the ParamsPanelProps.markers
|
||||||
// doc comment: its %-positions belong to the retired decorative SVG grid and
|
// doc comment: its %-positions belong to the retired decorative SVG grid and
|
||||||
// do not correspond to real lat/lon on the Leaflet map below.
|
// do not correspond to real lat/lon on the Leaflet map below.
|
||||||
|
|
@ -615,6 +665,16 @@ export default function ParamsPanel({
|
||||||
const radiusListId = useId();
|
const radiusListId = useId();
|
||||||
const [radiusActive, setRadiusActive] = useState(-1);
|
const [radiusActive, setRadiusActive] = useState(-1);
|
||||||
const [address, setAddress] = useState(initialValues?.address ?? "");
|
const [address, setAddress] = useState(initialValues?.address ?? "");
|
||||||
|
// Город формы (#2576 city_hint) — см. initCityLabel doc comment. Independent
|
||||||
|
// control от адреса: меняется через дропдаун ИЛИ автоопределяется из
|
||||||
|
// набранного/выбранного текста (см. handleAddressChange/pickSuggestion).
|
||||||
|
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) : "",
|
||||||
);
|
);
|
||||||
|
|
@ -676,7 +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);
|
||||||
const suggest = useGeocodeSuggest(addressQuery);
|
// #2576 / deep-review R2: подсказки скопированы по городу ТОЛЬКО когда он
|
||||||
|
// подтверждён (cityConfirmed) — иначе suggest() уходит без city_hint, и
|
||||||
|
// бэкенд смещает bias на всю область (OBLAST66_VIEWBOX), а не молча на ЕКБ.
|
||||||
|
// Это и есть исправление: нетронутая форма для «Ленина 1» теперь видит
|
||||||
|
// кандидатов из ВСЕЙ области (в т.ч. Нижний Тагил), а не только ЕКБ.
|
||||||
|
// 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);
|
||||||
|
|
@ -686,6 +754,18 @@ export default function ParamsPanel({
|
||||||
setAddressActive(-1); // a new query invalidates the previous highlight
|
setAddressActive(-1); // a new query invalidates the previous highlight
|
||||||
if (fieldErrors.address)
|
if (fieldErrors.address)
|
||||||
setFieldErrors((prev) => ({ ...prev, address: undefined }));
|
setFieldErrors((prev) => ({ ...prev, address: undefined }));
|
||||||
|
// Autodetect (#2576): если пользователь сам печатает город в тексте
|
||||||
|
// адреса ("Нижний Тагил, Ленина 1"), подхватываем его в дропдаун сразу —
|
||||||
|
// не ждём выбора подсказки. Найденное совпадение — реальное подтверждение
|
||||||
|
// (пользователь сам его написал), поэтому это ЕДИНСТВЕННОЕ место, где
|
||||||
|
// cityConfirmed включается без явного клика по дропдауну. Отсутствие
|
||||||
|
// совпадения НЕ трогает city/cityConfirmed — предыдущее подтверждение
|
||||||
|
// (если было) остаётся в силе при мелкой правке текста.
|
||||||
|
const detected = detectCityInText(v);
|
||||||
|
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);
|
||||||
};
|
};
|
||||||
|
|
@ -697,6 +777,30 @@ export default function ParamsPanel({
|
||||||
setFias(s.fias_id ?? null);
|
setFias(s.fias_id ?? null);
|
||||||
setSuggestOpen(false);
|
setSuggestOpen(false);
|
||||||
setAddressActive(-1);
|
setAddressActive(-1);
|
||||||
|
// Autodetect (#2576): выбор реального гео-резолвленного адреса — сильный
|
||||||
|
// сигнал подтверждения (даже сильнее, чем просто набранный текст).
|
||||||
|
// full_address обычно содержит город независимо от того, был ли отправлен
|
||||||
|
// city_hint (провайдер возвращает его как часть резолвленного адреса) —
|
||||||
|
// так первый же pick из НЕподтверждённого поиска (bias на всю область)
|
||||||
|
// подтверждает город без отдельного клика по дропдауну.
|
||||||
|
const detected = detectCityInText(s.full_address);
|
||||||
|
if (detected) {
|
||||||
|
setCity(detected);
|
||||||
|
setCityConfirmed(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Явный выбор города из дропдауна — прямое подтверждение пользователем
|
||||||
|
// (deep-review R2: единственный способ гарантированно включить cityConfirmed
|
||||||
|
// независимо от текста адреса). Сбрасывает ранее выбранные координаты/ФИАС —
|
||||||
|
// тот же текст адреса под другим городом это уже другая точка (зеркалит
|
||||||
|
// handleAddressChange's stale-guard для ручной правки).
|
||||||
|
const selectCity = (v: string) => {
|
||||||
|
setCity(v);
|
||||||
|
setCityConfirmed(true);
|
||||||
|
setOpenDd(null);
|
||||||
|
setCoords(null);
|
||||||
|
setFias(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Address combobox keyboard (M6). aria-activedescendant pattern: focus STAYS on
|
// Address combobox keyboard (M6). aria-activedescendant pattern: focus STAYS on
|
||||||
|
|
@ -830,6 +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 / deep-review R2 — город уходит ТОЛЬКО когда подтверждён
|
||||||
|
// (явный выбор в дропдауне или совпадение в тексте/подсказке); иначе
|
||||||
|
// city_hint не отправляется вовсе (undefined → ключ выпадает из JSON),
|
||||||
|
// и бэкенд честно помечает 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.)
|
||||||
|
|
@ -1384,12 +1494,45 @@ export default function ParamsPanel({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<div
|
||||||
htmlFor="pp-address"
|
style={{
|
||||||
style={{ ...hintLabel, display: "block" }}
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
АДРЕС (YANDEX / NOMINATIM)
|
<label htmlFor="pp-address" style={hintLabel}>
|
||||||
</label>
|
АДРЕС (YANDEX / NOMINATIM)
|
||||||
|
</label>
|
||||||
|
{/* Город (#2576) — компактный дропдаун рядом с лейблом адреса, не
|
||||||
|
отдельная строка: не отнимает вертикальное место у и так плотной
|
||||||
|
панели. Explicit pick сразу подтверждает город (city_hint
|
||||||
|
отправляется дальше). До подтверждения показывает
|
||||||
|
UNCONFIRMED_CITY_LABEL, НЕ "Екатеринбург" (deep-review R2) —
|
||||||
|
иначе дропдаун выдавал бы наш внутренний best-guess за
|
||||||
|
подтверждённый пользователем выбор, хотя ничего ещё не
|
||||||
|
отправлялось. Для ЕКБ-сценария это не лишний клик: как только
|
||||||
|
пользователь печатает/выбирает адрес, detectCityInText обычно
|
||||||
|
находит город сам (см. handleAddressChange/pickSuggestion) и
|
||||||
|
лейбл сменится на «Екатеринбург» автоматически. Список городов
|
||||||
|
растёт — см. city-registry.ts. */}
|
||||||
|
<div style={{ position: "relative", width: 176, flex: "0 0 auto" }}>
|
||||||
|
<Dd
|
||||||
|
open={openDd === "city"}
|
||||||
|
onToggle={() => toggle("city")}
|
||||||
|
value={cityConfirmed ? city : UNCONFIRMED_CITY_LABEL}
|
||||||
|
options={CITY_LABELS}
|
||||||
|
onSelect={selectCity}
|
||||||
|
mono={false}
|
||||||
|
triggerFontSize={11}
|
||||||
|
optionFontSize={11.5}
|
||||||
|
triggerHeight={22}
|
||||||
|
variant="dashed"
|
||||||
|
ariaLabel="Город"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
{/* Address autocomplete (geocode suggest, ЕКБ viewbox). Typed text
|
{/* Address autocomplete (geocode suggest, ЕКБ viewbox). Typed text
|
||||||
is debounced into `addressQuery`; the dropdown below mirrors the
|
is debounced into `addressQuery`; the dropdown below mirrors the
|
||||||
|
|
@ -1508,6 +1651,33 @@ export default function ParamsPanel({
|
||||||
{fieldErrors.address}
|
{fieldErrors.address}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* #2576 — честная, не блокирующая подсказка: последняя оценка
|
||||||
|
вернулась с target_city_ambiguous, город определил геокодер
|
||||||
|
сам. role="status" (не alert) — это информация, не ошибка.
|
||||||
|
Deep-review R3: текст различает cityConfirmed — если бэкенд
|
||||||
|
не получил city_hint (`cityConfirmed === false`), `city` в
|
||||||
|
этот момент внутренний best-guess ("Екатеринбург"), а НЕ то,
|
||||||
|
что реально нашёл геокодер (в ответе вообще нет угаданного
|
||||||
|
города, только сам булев признак) — называть конкретный город
|
||||||
|
в этом случае было бы той же нечестностью, которую фикс
|
||||||
|
убирал из запроса, только в тексте плашки. */}
|
||||||
|
{cityAmbiguous && !fieldErrors.address && (
|
||||||
|
<div style={cityAmbiguousText} role="status">
|
||||||
|
{cityConfirmed ? (
|
||||||
|
<>
|
||||||
|
Город определён автоматически — результат может
|
||||||
|
относиться к другому населённому пункту области. Если это
|
||||||
|
не {city}, выберите верный город выше и повторите оценку.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Город определён автоматически — результат может
|
||||||
|
относиться к другому населённому пункту области. Если это
|
||||||
|
неверно, выберите город выше и повторите оценку.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
93
tradein-mvp/frontend/src/lib/city-registry.ts
Normal file
93
tradein-mvp/frontend/src/lib/city-registry.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
/**
|
||||||
|
* city-registry — единый источник правды по городам Свердловской области,
|
||||||
|
* поддержанным сбором «Меры» (#2576 этап B: расширение на область).
|
||||||
|
*
|
||||||
|
* Раньше геокодер молча подставлял "Екатеринбург, " в любой запрос без явного
|
||||||
|
* города в тексте адреса — житель Нижнего Тагила, вводя «Ленина, 1», получал
|
||||||
|
* уверенно неверную цену по одноимённой екатеринбургской улице (backend fix
|
||||||
|
* #2576, PR #2580: `city_hint` query-параметр + `TradeInEstimateInput.city_hint`
|
||||||
|
* + честный `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). Parity-риск (deep-review R2, дёшево): эти 6 лейблов
|
||||||
|
* должны оставаться подмножеством `SVERDLOVSK_OBLAST_CITIES`
|
||||||
|
* (tradein-mvp/backend/app/services/geocoder.py) — та же кириллица в нижнем
|
||||||
|
* регистре. Расхождение не ломается типами (`city_hint` — свободный текст,
|
||||||
|
* бэкенд его не валидирует по списку), но подсунет город без реального
|
||||||
|
* покрытия скрейпером. Перед добавлением 7-го города — сверить оба списка
|
||||||
|
* вручную (codegen/тест на это пока нет, backend — другой PR).
|
||||||
|
*/
|
||||||
|
|
||||||
|
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: "Серов" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дефолт формы — Екатеринбург (визуальный/best-guess, для текста подсказки и
|
||||||
|
* подстановки значения ПОСЛЕ подтверждения). НЕ означает "отправляется по
|
||||||
|
* умолчанию" — см. предупреждение в шапке файла: пока город не подтверждён
|
||||||
|
* (`cityConfirmed` в ParamsPanel.tsx), в запрос ничего не уходит.
|
||||||
|
*/
|
||||||
|
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. */
|
||||||
|
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=
|
* GET /api/v1/geocode/suggest?q=&limit=&city_hint=
|
||||||
* Автокомплит адресов в пределах ЕКБ для поля адреса (ParamsPanel). Debounce-
|
* Автокомплит адресов в Свердловской области для поля адреса (ParamsPanel).
|
||||||
* friendly: вызывающий компонент дебаунсит строку query, хук кешируется по
|
* Debounce-friendly: вызывающий компонент дебаунсит строку query, хук
|
||||||
* queryKey; `enabled` срабатывает только начиная с 3 символов (бэкенд min 2,
|
* кешируется по queryKey; `enabled` срабатывает только начиная с 3 символов
|
||||||
* берём 3 чтобы не дёргать на 1-2 символа). `select` разворачивает обёртку
|
* (бэкенд min 2, берём 3 чтобы не дёргать на 1-2 символа). `select`
|
||||||
* {items} → GeocodeSuggestion[]; keepPreviousData убирает мерцание списка между
|
* разворачивает обёртку {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 q = query.trim();
|
||||||
|
const hint = (cityHint ?? "").trim();
|
||||||
return useQuery<GeocodeSuggestResponse, Error, GeocodeSuggestion[]>({
|
return useQuery<GeocodeSuggestResponse, Error, GeocodeSuggestion[]>({
|
||||||
queryKey: ["trade-in", "geocode-suggest", q, limit],
|
queryKey: ["trade-in", "geocode-suggest", q, hint, limit],
|
||||||
queryFn: () =>
|
queryFn: () => {
|
||||||
apiFetch<GeocodeSuggestResponse>(
|
const params = new URLSearchParams({ q, limit: String(limit) });
|
||||||
`${GEOCODE_BASE}/suggest?q=${encodeURIComponent(q)}&limit=${limit}`,
|
if (hint) params.set("city_hint", hint);
|
||||||
),
|
return apiFetch<GeocodeSuggestResponse>(
|
||||||
|
`${GEOCODE_BASE}/suggest?${params.toString()}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
select: (r) => r.items,
|
select: (r) => r.items,
|
||||||
enabled: q.length >= 3,
|
enabled: q.length >= 3,
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,13 @@ export interface TradeInEstimateInput {
|
||||||
// до геокодинга строки. Отправляется ТОЛЬКО когда адрес выбран из подсказки;
|
// до геокодинга строки. Отправляется ТОЛЬКО когда адрес выбран из подсказки;
|
||||||
// сбрасывается в null при ручной правке адреса или сдвиге пина на карте.
|
// сбрасывается в null при ручной правке адреса или сдвиге пина на карте.
|
||||||
target_fias_id?: string | 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 {
|
export interface AnalogLot {
|
||||||
|
|
@ -169,6 +176,12 @@ export interface AggregatedEstimate {
|
||||||
target_address: string | null;
|
target_address: string | null;
|
||||||
target_lat: number | null;
|
target_lat: number | null;
|
||||||
target_lon: 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']
|
sources_used: string[]; // ['avito', 'cian', 'rosreestr']
|
||||||
// #2043 (BE-1): достоверность выборки — реальный коэффициент вариации ₽/м² (std/mean),
|
// #2043 (BE-1): достоверность выборки — реальный коэффициент вариации ₽/м² (std/mean),
|
||||||
// счётчики аналогов по источнику, момент создания оценки. Все optional: старые
|
// счётчики аналогов по источнику, момент создания оценки. Все optional: старые
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue