"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({ mutationFn: (input) => apiFetch(`${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({ mutationFn: (input) => apiFetch(`${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({ queryKey: ["trade-in", "estimate", estimate_id], queryFn: () => apiFetch(`${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({ queryKey: ["trade-in", "estimate", estimate_id, "houses"], queryFn: () => apiFetch(`${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({ queryKey: ["trade-in", "estimate", estimate_id, "placement-history"], queryFn: () => apiFetch( `${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({ queryKey: ["trade-in", "estimate", estimate_id, "imv-benchmark"], queryFn: () => apiFetch(`${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({ queryKey: ["trade-in", "estimate", estimate_id, "cian-price-changes"], queryFn: () => apiFetch( `${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({ queryKey: ["trade-in", "estimate", estimate_id, "house-analytics"], queryFn: () => apiFetch( `${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({ queryKey: ["trade-in", "location-index", estimate_id, radius_m ?? null], queryFn: () => apiFetch(`${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({ queryKey: ["trade-in", "street-deals", address, area_m2, rooms], queryFn: () => apiFetch(`${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({ queryKey: ["trade-in", "sales-vs-listings", address, area_m2, rooms], queryFn: () => apiFetch(`${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({ queryKey: ["trade-in", "estimate", estimate_id, "sell-time-sensitivity"], queryFn: () => apiFetch( `${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({ queryKey: ["trade-in", "history", limit], queryFn: () => apiFetch(`${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({ 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( `${GEOCODE_BASE}/suggest?${params.toString()}`, ); }, select: (r) => r.items, enabled: q.length >= 3, staleTime: 5 * 60_000, retry: false, placeholderData: keepPreviousData, }); }