gendesign/tradein-mvp/frontend/src/lib/trade-in-api.ts
bot-backend 2779ef5e79
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
feat(tradein/ui): передавать город в геокодирование + честный признак неопределённости (#2576)
Раньше интерфейс город вообще не передавал — 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
в несвязанных файлах).
2026-07-31 17:08:46 +03:00

295 lines
11 KiB
TypeScript
Raw Permalink 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";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { apiFetch } from "./api";
import type {
AggregatedEstimate,
CianPriceChangeStats,
EstimateHistoryItem,
GeocodeSuggestion,
GeocodeSuggestResponse,
HouseAnalyticsResponse,
HouseInfoForEstimate,
IMVBenchmarkResponse,
LocationIndexResponse,
PlacementHistoryItem,
SalesVsListingsResponse,
SellTimeSensitivityResponse,
StreetDealsResponse,
TradeInEstimateInput,
TradeInLeadInput,
TradeInLeadResponse,
} from "@/types/trade-in";
const BASE = "/api/v1/trade-in";
const GEOCODE_BASE = "/api/v1/geocode";
/**
* POST /api/v1/trade-in/estimate
* Sends apartment parameters and returns AggregatedEstimate (mock data for now).
*/
export function useEstimateMutation() {
return useMutation<AggregatedEstimate, Error, TradeInEstimateInput>({
mutationFn: (input) =>
apiFetch<AggregatedEstimate>(`${BASE}/estimate`, {
method: "POST",
body: JSON.stringify(input),
}),
});
}
/**
* POST /api/v1/trade-in/lead
* Контактная заявка (issue #2377 / backend #2376, PR #2390) — телефон + явное
* согласие ФЗ-152, опционально привязанные к оценке (estimate_id). 422 если
* consent !== true, 404 если estimate_id не найден. Notification (Telegram/
* email) вне scope — только persist в trade_in_leads.
*/
export function useCreateLeadMutation() {
return useMutation<TradeInLeadResponse, Error, TradeInLeadInput>({
mutationFn: (input) =>
apiFetch<TradeInLeadResponse>(`${BASE}/lead`, {
method: "POST",
body: JSON.stringify(input),
}),
});
}
/**
* GET /api/v1/trade-in/estimate/{id}
* Fetches a previously computed estimate by UUID (for shareable links / PDF).
*/
export function useEstimate(estimate_id: string | null) {
return useQuery<AggregatedEstimate>({
queryKey: ["trade-in", "estimate", estimate_id],
queryFn: () =>
apiFetch<AggregatedEstimate>(`${BASE}/estimate/${estimate_id}`),
enabled: estimate_id !== null && estimate_id.length > 0,
staleTime: 10 * 60_000, // 10 min — estimates expire at expires_at anyway
});
}
/**
* GET /api/v1/trade-in/estimate/{id}/houses
* Houses в радиусе 500m от target адреса estimate (Stage 2c data).
*/
export function useEstimateHouses(estimate_id: string | null) {
return useQuery<HouseInfoForEstimate[]>({
queryKey: ["trade-in", "estimate", estimate_id, "houses"],
queryFn: () =>
apiFetch<HouseInfoForEstimate[]>(`${BASE}/estimate/${estimate_id}/houses`),
enabled: estimate_id !== null && estimate_id.length > 0,
staleTime: 10 * 60_000,
});
}
/**
* GET /api/v1/trade-in/estimate/{id}/placement-history
* Historical listings from house_placement_history for the target house.
*/
export function useEstimatePlacementHistory(estimate_id: string | null) {
return useQuery<PlacementHistoryItem[]>({
queryKey: ["trade-in", "estimate", estimate_id, "placement-history"],
queryFn: () =>
apiFetch<PlacementHistoryItem[]>(
`${BASE}/estimate/${estimate_id}/placement-history`,
),
enabled: estimate_id !== null && estimate_id.length > 0,
staleTime: 10 * 60_000,
});
}
/**
* GET /api/v1/trade-in/estimate/{id}/imv-benchmark
* Avito IMV benchmark для UI badge (Stage 3 IMV cache lookup).
*/
export function useEstimateImvBenchmark(estimate_id: string | null) {
return useQuery<IMVBenchmarkResponse>({
queryKey: ["trade-in", "estimate", estimate_id, "imv-benchmark"],
queryFn: () =>
apiFetch<IMVBenchmarkResponse>(`${BASE}/estimate/${estimate_id}/imv-benchmark`),
enabled: estimate_id !== null && estimate_id.length > 0,
staleTime: 10 * 60_000,
});
}
/**
* GET /api/v1/trade-in/estimate/{id}/cian-price-changes
* Price change history for Cian analog listings (only those with >0 changes).
*/
export function useEstimateCianPriceChanges(estimate_id: string | null) {
return useQuery<CianPriceChangeStats[]>({
queryKey: ["trade-in", "estimate", estimate_id, "cian-price-changes"],
queryFn: () =>
apiFetch<CianPriceChangeStats[]>(
`${BASE}/estimate/${estimate_id}/cian-price-changes`,
),
enabled: estimate_id !== null && estimate_id.length > 0,
staleTime: 10 * 60_000,
});
}
/**
* GET /api/v1/trade-in/estimate/{id}/house-analytics
* Price history, KPI and recent sold lots for the target house.
*/
export function useEstimateHouseAnalytics(estimate_id: string | null) {
return useQuery<HouseAnalyticsResponse>({
queryKey: ["trade-in", "estimate", estimate_id, "house-analytics"],
queryFn: () =>
apiFetch<HouseAnalyticsResponse>(
`${BASE}/estimate/${estimate_id}/house-analytics`,
),
enabled: estimate_id !== null && estimate_id.length > 0,
staleTime: 10 * 60_000,
});
}
/**
* GET /api/v1/trade-in/location-index?estimate_id=&radius_m=
* Location index for the estimate's target address — replaces the broken
* location-coef (backend rewrite, see app/services/location_index.py):
* % deviation of the local median ₽/м² (comparable active listings near the
* address) from the citywide median ₽/м², NOT a price multiplier and NOT fed
* into the estimate. status="out_of_coverage"/"insufficient_data" are honest
* graceful-fallback responses (address outside Yekaterinburg / too few
* comparables) — ./v2/mappers.ts renders each distinctly, never a fabricated
* number.
*/
export function useLocationIndex(estimate_id: string | null, radius_m?: number) {
const params = new URLSearchParams();
if (estimate_id) params.set("estimate_id", estimate_id);
if (radius_m != null) params.set("radius_m", String(radius_m));
return useQuery<LocationIndexResponse>({
queryKey: ["trade-in", "location-index", estimate_id, radius_m ?? null],
queryFn: () =>
apiFetch<LocationIndexResponse>(`${BASE}/location-index?${params}`),
enabled: estimate_id !== null && estimate_id.length > 0,
staleTime: 10 * 60_000,
});
}
/**
* GET /api/v1/trade-in/street-deals
* ДКП-сделки Росреестра по улице target адреса. Per-street (open dataset
* без номера дома). Empty response если street не извлёкся.
*/
export function useStreetDeals(
address: string | null,
area_m2: number | null,
rooms: number | null,
) {
const params = new URLSearchParams();
if (address) params.set("address", address);
if (area_m2 !== null) params.set("area_m2", String(area_m2));
if (rooms !== null) params.set("rooms", String(rooms));
return useQuery<StreetDealsResponse>({
queryKey: ["trade-in", "street-deals", address, area_m2, rooms],
queryFn: () =>
apiFetch<StreetDealsResponse>(`${BASE}/street-deals?${params}`),
enabled: !!address && area_m2 !== null && rooms !== null,
staleTime: 5 * 60_000,
});
}
/**
* GET /api/v1/trade-in/sales-vs-listings
* ДКП-сделки + paired listings по улице (PR L / #564 Phase 2). Возвращает все
* пары LEFT JOIN — сделки без listing match сохраняются (listing_* = null),
* чтобы вычислить linkage_rate. Используется в StreetDealsCard для показа
* исторических ASK рядом с фактической ценой сделки.
*
* staleTime: 10 минут — данные обновляются раз в сутки (rosreestr import +
* scrapers), нет смысла часто рефетчить.
*/
export function useSalesVsListings(
address: string | null,
area_m2: number | null,
rooms: number | null,
) {
const params = new URLSearchParams();
if (address) params.set("address", address);
if (area_m2 !== null) params.set("area_m2", String(area_m2));
if (rooms !== null) params.set("rooms", String(rooms));
return useQuery<SalesVsListingsResponse>({
queryKey: ["trade-in", "sales-vs-listings", address, area_m2, rooms],
queryFn: () =>
apiFetch<SalesVsListingsResponse>(`${BASE}/sales-vs-listings?${params}`),
enabled: !!address && area_m2 !== null && rooms !== null,
staleTime: 10 * 60_000,
});
}
/**
* GET /api/v1/trade-in/estimate/{id}/sell-time-sensitivity
* Median exposure days bucketed by price premium (-5%, 0, +5%, +10%).
*/
export function useEstimateSellTimeSensitivity(estimate_id: string | null) {
return useQuery<SellTimeSensitivityResponse>({
queryKey: ["trade-in", "estimate", estimate_id, "sell-time-sensitivity"],
queryFn: () =>
apiFetch<SellTimeSensitivityResponse>(
`${BASE}/estimate/${estimate_id}/sell-time-sensitivity`,
),
enabled: estimate_id !== null && estimate_id.length > 0,
staleTime: 10 * 60_000,
});
}
/**
* GET /api/v1/trade-in/history?limit=N
* Per-user список последних оценок (CacheView · «Предыдущие оценки»). Скоупится
* по X-Authenticated-User в бэке (#656) — header проставляет Caddy basic_auth.
*
* Fail-soft как useQuota: `retry:false` чтобы 401 на протухшей сессии не
* ре-стучался. Освежать после успешной оценки явным invalidateQueries(
* ["trade-in","history"]).
*/
export function useEstimateHistory(limit = 50) {
return useQuery<EstimateHistoryItem[]>({
queryKey: ["trade-in", "history", limit],
queryFn: () =>
apiFetch<EstimateHistoryItem[]>(`${BASE}/history?limit=${limit}`),
staleTime: 30_000,
retry: false,
});
}
/**
* 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,
cityHint?: string | null,
limit = 8,
) {
const q = query.trim();
const hint = (cityHint ?? "").trim();
return useQuery<GeocodeSuggestResponse, Error, GeocodeSuggestion[]>({
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,
retry: false,
placeholderData: keepPreviousData,
});
}