393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
/**
|
||
* 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<ParcelBboxItem[]> => {
|
||
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<RecentParcel[]> => {
|
||
const localItems = getLocalRecentParcels();
|
||
|
||
if (MOCK_RECENT_PARCELS) {
|
||
return localItems;
|
||
}
|
||
|
||
try {
|
||
const serverData = await apiFetch<RecentParcelsResponse>(
|
||
"/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<ParcelAnalyzeResponse> => {
|
||
if (MOCK_ANALYZE) {
|
||
// Return fixture data regardless of cad — for dev only
|
||
return fixtureAnalyze as ParcelAnalyzeResponse;
|
||
}
|
||
return apiFetch<ParcelAnalyzeResponse>(
|
||
`/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<PoiScoreResponse> => {
|
||
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<PoiScoreRaw>(
|
||
`/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,
|
||
});
|
||
}
|