/** * Site Finder API hooks — TanStack Query wrappers. * * Mock fallback strategy (from mock-toggle.ts): * MOCK_PARCELS_BBOX — uses parcels-bbox.json fixture (B1 not yet in prod) * MOCK_RECENT_PARCELS — uses localStorage only (B2 stub not yet in prod) * MOCK_ANALYZE — uses parcel-analyze.json fixture (B5) * MOCK_POI_SCORE — uses poi-score.json fixture (B6) */ import { useQuery } from "@tanstack/react-query"; import { apiFetch } from "@/lib/api"; import { MOCK_PARCELS_BBOX, MOCK_RECENT_PARCELS, MOCK_ANALYZE, MOCK_POI_SCORE, } from "@/lib/mock-toggle"; import fixtureParcels from "@/lib/mocks/parcels-bbox.json"; import fixtureAnalyze from "@/lib/mocks/parcel-analyze.json"; import fixturePoiScore from "@/lib/mocks/poi-score.json"; // ── Types ───────────────────────────────────────────────────────────────────── export type ParcelStatus = "free" | "in_progress" | "favorite"; export type ParcelVri = | "multistory" | "mixed" | "individual" | "office" | "other"; export interface ParcelBboxItem { cad_num: string; address: string; area_ha: number; status: ParcelStatus; district: string; vri: ParcelVri; lat: number; lon: number; } export interface ParcelBboxFilters { min_area?: number; max_area?: number; status?: ParcelStatus; district?: string; vri?: ParcelVri; } export interface BboxCoords { minLat: number; minLon: number; maxLat: number; maxLon: number; } export interface RecentParcel { cad_num: string; address: string; area_ha: number; district: string; visited_at: string; // ISO string } // ── Constants ───────────────────────────────────────────────────────────────── const LOCAL_STORAGE_KEY = "gd_recent_parcels"; const MAX_RECENT = 10; // ── localStorage helpers ────────────────────────────────────────────────────── export function getLocalRecentParcels(): RecentParcel[] { if (typeof window === "undefined") return []; try { const raw = localStorage.getItem(LOCAL_STORAGE_KEY); if (!raw) return []; const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed as RecentParcel[]; } catch { return []; } } export function addLocalRecentParcel(parcel: RecentParcel): void { if (typeof window === "undefined") return; try { const existing = getLocalRecentParcels(); const filtered = existing.filter((p) => p.cad_num !== parcel.cad_num); const updated = [parcel, ...filtered].slice(0, MAX_RECENT); localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(updated)); } catch { // ignore localStorage errors } } // ── Hook: useParcelsBboxQuery ───────────────────────────────────────────────── function applyFilters( parcels: ParcelBboxItem[], filters: ParcelBboxFilters, ): ParcelBboxItem[] { return parcels.filter((p) => { if (filters.min_area != null && p.area_ha < filters.min_area) return false; if (filters.max_area != null && p.area_ha > filters.max_area) return false; if (filters.status != null && p.status !== filters.status) return false; if ( filters.district != null && filters.district !== "" && p.district !== filters.district ) return false; if (filters.vri != null && filters.vri !== "other" && p.vri !== filters.vri) return false; return true; }); } function filterByBbox( parcels: ParcelBboxItem[], bbox: BboxCoords, ): ParcelBboxItem[] { return parcels.filter( (p) => p.lat >= bbox.minLat && p.lat <= bbox.maxLat && p.lon >= bbox.minLon && p.lon <= bbox.maxLon, ); } export function useParcelsBboxQuery( bbox: BboxCoords | null, filters: ParcelBboxFilters = {}, ) { return useQuery({ queryKey: [ "parcels-bbox", bbox?.minLat, bbox?.minLon, bbox?.maxLat, bbox?.maxLon, filters.min_area, filters.max_area, filters.status, filters.district, filters.vri, ], queryFn: async (): Promise => { if (MOCK_PARCELS_BBOX) { // Fixture: filter by bbox + filters client-side const typed = fixtureParcels as ParcelBboxItem[]; const inBbox = bbox ? filterByBbox(typed, bbox) : typed; return applyFilters(inBbox, filters); } if (!bbox) return []; const params = new URLSearchParams({ min_lat: String(bbox.minLat), min_lon: String(bbox.minLon), max_lat: String(bbox.maxLat), max_lon: String(bbox.maxLon), }); if (filters.min_area != null) params.set("min_area", String(filters.min_area)); if (filters.max_area != null) params.set("max_area", String(filters.max_area)); if (filters.status) params.set("status", filters.status); if (filters.district) params.set("district", filters.district); if (filters.vri) params.set("vri", filters.vri); // Backend returns { parcels, count, limit, bbox_area_km2 } with field // names centroid_lat/centroid_lon/area_m2 and nullable status. Adapt to // the flat ParcelBboxItem[] shape the map components consume. district / // vri / address are not in B1 yet — defaulted until enrichment lands. const raw = await apiFetch<{ parcels: Array<{ cad_num: string; centroid_lat: number; centroid_lon: number; area_m2: number | null; land_category: string | null; status: ParcelStatus | null; }>; }>(`/api/v1/parcels/by-bbox?${params.toString()}`); const adapted: ParcelBboxItem[] = raw.parcels.map((p) => ({ cad_num: p.cad_num, lat: p.centroid_lat, lon: p.centroid_lon, area_ha: p.area_m2 != null ? p.area_m2 / 10000 : 0, status: p.status ?? "free", district: "—", vri: "other", address: "", })); // Apply filters client-side until backend supports them (B1 follow-up) return applyFilters(adapted, filters); }, enabled: MOCK_PARCELS_BBOX || bbox != null, staleTime: 30_000, }); } // ── Hook: useRecentParcels ──────────────────────────────────────────────────── interface RecentParcelsResponse { parcels: RecentParcel[]; } export function useRecentParcels() { return useQuery({ queryKey: ["recent-parcels"], queryFn: async (): Promise => { const localItems = getLocalRecentParcels(); if (MOCK_RECENT_PARCELS) { return localItems; } try { const serverData = await apiFetch( "/api/v1/users/me/recent-parcels", ); // Merge: server list + local items not already in server list const serverCads = new Set(serverData.parcels.map((p) => p.cad_num)); const localOnly = localItems.filter((p) => !serverCads.has(p.cad_num)); return [...serverData.parcels, ...localOnly].slice(0, MAX_RECENT); } catch { // B2 stub may not be deployed yet — fall back to localStorage return localItems; } }, staleTime: 60_000, }); } // ── Types: B5 extended analyze response ────────────────────────────────────── export interface ParcelEgrn { cad_num: string; address: string; area_m2: number; vri: string; category: string; registration_date: string | null; owner_type: string; encumbrance: string; status: string; last_updated: string | null; } // ── Types: utilities (engineering nearby) ──────────────────────────────────── export type UtilitySubtype = | "substation" | "pipeline" | "power_line" | "water_intake" | "pumping_station" | string; // open for future subtypes export interface UtilitySummaryItem { subtype: UtilitySubtype; nearest_m: number; name: string | null; count_within_2km: number; } export interface UtilitiesData { summary: UtilitySummaryItem[]; power_line_охранная_зона_flag: boolean; note: string | null; } export interface ParcelAnalyzeResponse { cad_num: string; source: string; geom_geojson: unknown; district: { district_name: string; median_price_per_m2: number; dist_to_center: number; } | null; score: number; score_label?: string; score_explanation?: string; score_breakdown: Record< string, Array<{ name: string | null; distance_m: number; last_edit: string | null; lat: number; lon: number; }> >; poi_count: number; /** EGRN data — may be null if B5 extended not yet deployed */ egrn?: ParcelEgrn | null; /** Engineering / utilities nearby — present when NSPD data available */ utilities?: UtilitiesData | null; } // ── Types: B6 poi-score response ───────────────────────────────────────────── export interface PoiScoreItem { category: string; name: string; distance_m: number; weight: number; score_contribution: number; } export interface PoiScoreResponse { cad_num: string; poi_weighted_score: number; items: PoiScoreItem[]; } // ── Hook: useParcelAnalyzeQuery (B5) ───────────────────────────────────────── export function useParcelAnalyzeQuery(cad: string) { return useQuery({ queryKey: ["parcel-analyze", cad], queryFn: async (): Promise => { if (MOCK_ANALYZE) { // Return fixture data regardless of cad — for dev only return fixtureAnalyze as ParcelAnalyzeResponse; } return apiFetch( `/api/v1/parcels/${encodeURIComponent(cad)}/analyze`, { method: "POST" }, ); }, staleTime: 5 * 60_000, // 5 min — analyze is expensive retry: 1, }); } // ── Hook: useParcelPoiScoreQuery (B6) ──────────────────────────────────────── /** * Raw shape from backend /api/v1/parcels/{cad}/poi-score. * Backend returns top_poi array with address field (not score_contribution). * We adapt to PoiScoreResponse so downstream components are stable. */ interface PoiScoreRaw { cad_num: string; radius_m: number; top_poi: Array<{ name: string; category: string; distance_m: number; weight: number; address?: string | null; }>; } export function useParcelPoiScoreQuery(cad: string) { return useQuery({ queryKey: ["parcel-poi-score", cad], queryFn: async (): Promise => { if (MOCK_POI_SCORE) { return fixturePoiScore as PoiScoreResponse; } // Backend returns {cad_num, radius_m, top_poi: [{name,category,distance_m,weight,address}]} // Adapt to PoiScoreResponse {cad_num, poi_weighted_score, items} for stable consumer API. const raw = await apiFetch( `/api/v1/parcels/${encodeURIComponent(cad)}/poi-score`, ); const items: PoiScoreItem[] = (raw.top_poi ?? []).map((p) => ({ category: p.category, name: p.name, distance_m: p.distance_m, weight: p.weight, // score_contribution not in backend response — derive from weight (0..1) × 100 score_contribution: Math.round(p.weight * 100), })); const poi_weighted_score = items.reduce( (s, p) => s + p.score_contribution, 0, ); return { cad_num: raw.cad_num, poi_weighted_score, items }; }, staleTime: 5 * 60_000, retry: 1, }); }