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 в несвязанных файлах).
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
"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,
|
||
});
|
||
}
|