diff --git a/frontend/src/app/site-finder/page.tsx b/frontend/src/app/site-finder/page.tsx
index 54ed66b4..3fe11222 100644
--- a/frontend/src/app/site-finder/page.tsx
+++ b/frontend/src/app/site-finder/page.tsx
@@ -1,101 +1,87 @@
"use client";
+import dynamic from "next/dynamic";
import Link from "next/link";
-import { MapPin } from "lucide-react";
+import { useState } from "react";
-// ── Placeholder components ─────────────────────────────────────────────────
-// Replaced in A2 with , ,
+import { MapFilterBar } from "@/components/site-finder/entry/MapFilterBar";
+import { ParcelDrawer } from "@/components/site-finder/entry/ParcelDrawer";
+import { ParcelLegend } from "@/components/site-finder/entry/ParcelLegend";
+import { RecentParcels } from "@/components/site-finder/entry/RecentParcels";
+import { useParcelsBboxQuery } from "@/lib/site-finder-api";
+import type {
+ ParcelBboxFilters,
+ ParcelBboxItem,
+ BboxCoords,
+} from "@/lib/site-finder-api";
-function MapPlaceholder() {
- return (
-
-
- TODO A2: EntryMap — Leaflet full-screen ЕКБ + parcel layers
-
- Данные: GET /api/v1/parcels/by-bbox (B1 ready)
-
-
- );
+// EntryMap uses Leaflet — must load without SSR
+const EntryMap = dynamic(
+ () =>
+ import("@/components/site-finder/entry/EntryMap").then((m) => m.EntryMap),
+ {
+ ssr: false,
+ loading: () => (
+
+ Загрузка карты...
+
+ ),
+ },
+);
+
+// ── FilterCountBridge ──────────────────────────────────────────────────────────
+// Reads parcel counts for current bbox without re-mounting EntryMap.
+
+interface FilterBarBridgeProps {
+ filters: ParcelBboxFilters;
+ onChange: (f: ParcelBboxFilters) => void;
+ bbox: BboxCoords | null;
}
-function SidebarPlaceholder() {
+function FilterBarBridge({ filters, onChange, bbox }: FilterBarBridgeProps) {
+ const { data: allInBbox } = useParcelsBboxQuery(bbox, {});
+ const { data: filtered } = useParcelsBboxQuery(bbox, filters);
+
return (
-
+
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function SiteFinderPage() {
+ const [filters, setFilters] = useState({});
+ const [selectedParcel, setSelectedParcel] = useState(
+ null,
+ );
+ const [bbox, setBbox] = useState(null);
+
+ function handleParcelSelect(parcel: ParcelBboxItem) {
+ setSelectedParcel(parcel);
+ }
+
+ function handleParcelDeselect() {
+ setSelectedParcel(null);
+ }
+
return (
SiteFinder · карта участков
- {/* TODO A4: справа */}
- {/* TODO A2: MapFilterBar top-bar с chips (free/area/vri/district) + counter «148 / 34 подходят» */}
+ {/* Filter bar */}
+
+
+
- {/* Main layout: карта слева, sidebar справа */}
+ {/* Main layout */}
{/* Map area */}
- {/* TODO A2: replace with */}
-
+
{/* Right sidebar */}
- {/* TODO A2:
+
+
*/}
-
+
+
- {/* TODO A2: — slide-in справа при клике на парцель (query ?selected=cad) */}
+ {/* Parcel drawer (slide-in from right) */}
+
);
}
diff --git a/frontend/src/components/site-finder/entry/EntryMap.tsx b/frontend/src/components/site-finder/entry/EntryMap.tsx
new file mode 100644
index 00000000..4160d1c8
--- /dev/null
+++ b/frontend/src/components/site-finder/entry/EntryMap.tsx
@@ -0,0 +1,224 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ MapContainer,
+ TileLayer,
+ CircleMarker,
+ Tooltip,
+ useMapEvents,
+} from "react-leaflet";
+import type { LeafletMouseEvent, Map as LeafletMap } from "leaflet";
+import "leaflet/dist/leaflet.css";
+
+import type {
+ ParcelBboxItem,
+ BboxCoords,
+ ParcelBboxFilters,
+} from "@/lib/site-finder-api";
+import { useParcelsBboxQuery } from "@/lib/site-finder-api";
+import { STATUS_COLORS } from "./ParcelLegend";
+
+// ── Bbox change listener ───────────────────────────────────────────────────────
+
+interface BboxListenerProps {
+ onBboxChange: (bbox: BboxCoords) => void;
+}
+
+function BboxListener({ onBboxChange }: BboxListenerProps) {
+ const map = useMapEvents({
+ moveend() {
+ const bounds = map.getBounds();
+ onBboxChange({
+ minLat: bounds.getSouth(),
+ minLon: bounds.getWest(),
+ maxLat: bounds.getNorth(),
+ maxLon: bounds.getEast(),
+ });
+ },
+ zoomend() {
+ const bounds = map.getBounds();
+ onBboxChange({
+ minLat: bounds.getSouth(),
+ minLon: bounds.getWest(),
+ maxLat: bounds.getNorth(),
+ maxLon: bounds.getEast(),
+ });
+ },
+ });
+
+ // Emit initial bbox on mount
+ useEffect(() => {
+ const bounds = map.getBounds();
+ onBboxChange({
+ minLat: bounds.getSouth(),
+ minLon: bounds.getWest(),
+ maxLat: bounds.getNorth(),
+ maxLon: bounds.getEast(),
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return null;
+}
+
+// ── Parcel markers ─────────────────────────────────────────────────────────────
+
+interface ParcelMarkersProps {
+ parcels: ParcelBboxItem[];
+ selectedCad: string | null;
+ onSelect: (parcel: ParcelBboxItem) => void;
+}
+
+function ParcelMarkers({ parcels, selectedCad, onSelect }: ParcelMarkersProps) {
+ return (
+ <>
+ {parcels.map((parcel) => {
+ const color = STATUS_COLORS[parcel.status] ?? "#73767E";
+ const isSelected = parcel.cad_num === selectedCad;
+ return (
+ {
+ e.originalEvent.stopPropagation();
+ onSelect(parcel);
+ },
+ }}
+ >
+
+
+
{parcel.cad_num}
+
+ {parcel.area_ha.toFixed(2)} га · {parcel.district}
+
+
+
+
+ );
+ })}
+ >
+ );
+}
+
+// ── EntryMap ───────────────────────────────────────────────────────────────────
+
+// Yekaterinburg center
+const EKB_CENTER: [number, number] = [56.8389, 60.6057];
+const DEFAULT_ZOOM = 12;
+
+interface EntryMapProps {
+ filters: ParcelBboxFilters;
+ selectedCad: string | null;
+ onParcelSelect: (parcel: ParcelBboxItem) => void;
+ onParcelDeselect: () => void;
+ /** Called whenever the map viewport changes — allows parent to read bbox for filter counters */
+ onBboxChange?: (bbox: BboxCoords) => void;
+}
+
+export function EntryMap({
+ filters,
+ selectedCad,
+ onParcelSelect,
+ onParcelDeselect,
+ onBboxChange,
+}: EntryMapProps) {
+ const [bbox, setBbox] = useState(null);
+ const mapRef = useRef(null);
+
+ const { data: parcels, isFetching } = useParcelsBboxQuery(bbox, filters);
+
+ const handleBboxChange = useCallback(
+ (newBbox: BboxCoords) => {
+ setBbox(newBbox);
+ onBboxChange?.(newBbox);
+ },
+ [onBboxChange],
+ );
+
+ return (
+
+
+
+
+ {parcels && parcels.length > 0 && (
+
+ )}
+
+
+ {/* Deselect on map click — overlay to capture clicks outside markers */}
+ {selectedCad && (
+
+ )}
+
+ {/* Fetching indicator */}
+ {isFetching && (
+
+ Загрузка участков...
+
+ )}
+
+ {/* Empty state */}
+ {!isFetching && parcels && parcels.length === 0 && (
+
+ Нет участков в текущем виде. Измените фильтры или область карты.
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/entry/MapFilterBar.tsx b/frontend/src/components/site-finder/entry/MapFilterBar.tsx
new file mode 100644
index 00000000..10c3a559
--- /dev/null
+++ b/frontend/src/components/site-finder/entry/MapFilterBar.tsx
@@ -0,0 +1,257 @@
+"use client";
+
+import type { ChangeEvent } from "react";
+import type {
+ ParcelBboxFilters,
+ ParcelStatus,
+ ParcelVri,
+} from "@/lib/site-finder-api";
+
+interface MapFilterBarProps {
+ filters: ParcelBboxFilters;
+ onChange: (filters: ParcelBboxFilters) => void;
+ totalCount: number;
+ matchCount: number;
+}
+
+interface ChipProps {
+ label: string;
+ selected: boolean;
+ onClick: () => void;
+}
+
+function Chip({ label, selected, onClick }: ChipProps) {
+ return (
+
+ );
+}
+
+const DISTRICTS = [
+ "Ленинский",
+ "Верх-Исетский",
+ "Орджоникидзевский",
+ "Чкаловский",
+ "Кировский",
+ "Октябрьский",
+];
+
+const VRI_OPTIONS: Array<{ value: ParcelVri; label: string }> = [
+ { value: "multistory", label: "МКД" },
+ { value: "mixed", label: "Смешанный" },
+ { value: "individual", label: "ИЖС" },
+ { value: "office", label: "Офисный" },
+];
+
+const AREA_OPTIONS: Array<{
+ label: string;
+ min?: number;
+ max?: number;
+}> = [
+ { label: "до 0.5 га", max: 0.5 },
+ { label: "0.5–1 га", min: 0.5, max: 1 },
+ { label: "1–3 га", min: 1, max: 3 },
+ { label: "от 3 га", min: 3 },
+];
+
+export function MapFilterBar({
+ filters,
+ onChange,
+ totalCount,
+ matchCount,
+}: MapFilterBarProps) {
+ function toggleStatus(s: ParcelStatus) {
+ onChange({ ...filters, status: filters.status === s ? undefined : s });
+ }
+
+ function toggleDistrict(d: string) {
+ onChange({
+ ...filters,
+ district: filters.district === d ? undefined : d,
+ });
+ }
+
+ function toggleVri(v: ParcelVri) {
+ onChange({ ...filters, vri: filters.vri === v ? undefined : v });
+ }
+
+ function toggleArea(min?: number, max?: number) {
+ const same = filters.min_area === min && filters.max_area === max;
+ onChange({
+ ...filters,
+ min_area: same ? undefined : min,
+ max_area: same ? undefined : max,
+ });
+ }
+
+ function clearAll() {
+ onChange({});
+ }
+
+ const hasFilters =
+ filters.status != null ||
+ filters.district != null ||
+ filters.vri != null ||
+ filters.min_area != null ||
+ filters.max_area != null;
+
+ return (
+
+ {/* Counter */}
+
+ {matchCount.toLocaleString("ru")} / {totalCount.toLocaleString("ru")}{" "}
+ участков
+
+
+ {/* Status chips */}
+ toggleStatus("free")}
+ />
+ toggleStatus("in_progress")}
+ />
+ toggleStatus("favorite")}
+ />
+
+ {/* Separator */}
+
+
+ {/* Area chips */}
+ {AREA_OPTIONS.map((opt) => (
+ toggleArea(opt.min, opt.max)}
+ />
+ ))}
+
+ {/* Separator */}
+
+
+ {/* VRI chips */}
+ {VRI_OPTIONS.map((opt) => (
+ toggleVri(opt.value)}
+ />
+ ))}
+
+ {/* Separator */}
+
+
+ {/* District selector */}
+
+
+ {/* Clear all */}
+ {hasFilters && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/entry/ParcelDrawer.tsx b/frontend/src/components/site-finder/entry/ParcelDrawer.tsx
new file mode 100644
index 00000000..f8914c69
--- /dev/null
+++ b/frontend/src/components/site-finder/entry/ParcelDrawer.tsx
@@ -0,0 +1,374 @@
+"use client";
+
+import Link from "next/link";
+import { X, MapPin, Maximize2, ArrowRight } from "lucide-react";
+import type { ParcelBboxItem } from "@/lib/site-finder-api";
+import { STATUS_COLORS, STATUS_LABELS } from "./ParcelLegend";
+
+interface ParcelDrawerProps {
+ parcel: ParcelBboxItem | null;
+ onClose: () => void;
+}
+
+const VRI_LABELS: Record = {
+ multistory: "Многоэтажный",
+ mixed: "Смешанный",
+ individual: "ИЖС",
+ office: "Офисный",
+ other: "Иное",
+};
+
+export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
+ if (!parcel) return null;
+
+ const statusColor = STATUS_COLORS[parcel.status] ?? "#73767E";
+ const statusLabel = STATUS_LABELS[parcel.status] ?? parcel.status;
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Drawer panel */}
+
+ >
+ );
+}
diff --git a/frontend/src/components/site-finder/entry/ParcelLegend.tsx b/frontend/src/components/site-finder/entry/ParcelLegend.tsx
new file mode 100644
index 00000000..052df8fd
--- /dev/null
+++ b/frontend/src/components/site-finder/entry/ParcelLegend.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+// Status color constants — kept in sync with EntryMap marker colours
+export const STATUS_COLORS: Record = {
+ free: "#0A7A3A",
+ in_progress: "#9A6700",
+ favorite: "#1D4ED8",
+};
+
+export const STATUS_LABELS: Record = {
+ free: "Свободный",
+ in_progress: "В работе",
+ favorite: "Избранный",
+};
+
+export function ParcelLegend() {
+ const entries = Object.keys(STATUS_LABELS) as Array<
+ keyof typeof STATUS_LABELS
+ >;
+
+ return (
+
+
+ Статус участка
+
+ {entries.map((status) => (
+
+
+
+ {STATUS_LABELS[status]}
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/entry/RecentParcels.tsx b/frontend/src/components/site-finder/entry/RecentParcels.tsx
new file mode 100644
index 00000000..8876045f
--- /dev/null
+++ b/frontend/src/components/site-finder/entry/RecentParcels.tsx
@@ -0,0 +1,201 @@
+"use client";
+
+import Link from "next/link";
+import { Clock, MapPin } from "lucide-react";
+import { useRecentParcels } from "@/lib/site-finder-api";
+
+const MAX_SHOWN = 5;
+
+export function RecentParcels() {
+ const { data: parcels, isLoading } = useRecentParcels();
+
+ if (isLoading) {
+ return (
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ );
+ }
+
+ const shown = (parcels ?? []).slice(0, MAX_SHOWN);
+
+ if (shown.length === 0) {
+ return (
+
+
+
+
+ Недавние участки
+
+
+
+ История просмотров пуста
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ Недавние участки
+
+
+
+ {/* List */}
+
+ {shown.map((parcel, idx) => (
+
{
+ (e.currentTarget as HTMLElement).style.background =
+ "var(--bg-card-alt)";
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.background = "transparent";
+ }}
+ >
+
+
+
+ {parcel.cad_num}
+
+
+ {parcel.district}
+ {parcel.area_ha ? ` · ${parcel.area_ha.toFixed(2)} га` : ""}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/lib/mocks/parcels-bbox.json b/frontend/src/lib/mocks/parcels-bbox.json
new file mode 100644
index 00000000..42ff52cb
--- /dev/null
+++ b/frontend/src/lib/mocks/parcels-bbox.json
@@ -0,0 +1,202 @@
+[
+ {
+ "cad_num": "66:41:0101001:10",
+ "address": "Екатеринбург, ул. Ленина, 1",
+ "area_ha": 0.45,
+ "status": "free",
+ "district": "Ленинский",
+ "vri": "multistory",
+ "lat": 56.8389,
+ "lon": 60.6057
+ },
+ {
+ "cad_num": "66:41:0101002:23",
+ "address": "Екатеринбург, ул. Малышева, 51",
+ "area_ha": 0.82,
+ "status": "in_progress",
+ "district": "Ленинский",
+ "vri": "multistory",
+ "lat": 56.8377,
+ "lon": 60.6121
+ },
+ {
+ "cad_num": "66:41:0201001:5",
+ "address": "Екатеринбург, ул. Луначарского, 14",
+ "area_ha": 1.2,
+ "status": "free",
+ "district": "Верх-Исетский",
+ "vri": "mixed",
+ "lat": 56.8451,
+ "lon": 60.5987
+ },
+ {
+ "cad_num": "66:41:0301001:8",
+ "address": "Екатеринбург, пр. Космонавтов, 3",
+ "area_ha": 0.67,
+ "status": "favorite",
+ "district": "Орджоникидзевский",
+ "vri": "multistory",
+ "lat": 56.8762,
+ "lon": 60.6214
+ },
+ {
+ "cad_num": "66:41:0302001:12",
+ "address": "Екатеринбург, ул. Победы, 80",
+ "area_ha": 0.33,
+ "status": "free",
+ "district": "Орджоникидзевский",
+ "vri": "individual",
+ "lat": 56.8801,
+ "lon": 60.6189
+ },
+ {
+ "cad_num": "66:41:0401001:17",
+ "address": "Екатеринбург, ул. Амундсена, 107",
+ "area_ha": 1.85,
+ "status": "free",
+ "district": "Чкаловский",
+ "vri": "multistory",
+ "lat": 56.7998,
+ "lon": 60.6342
+ },
+ {
+ "cad_num": "66:41:0402001:31",
+ "address": "Екатеринбург, ул. Щербакова, 4",
+ "area_ha": 0.54,
+ "status": "in_progress",
+ "district": "Чкаловский",
+ "vri": "mixed",
+ "lat": 56.8021,
+ "lon": 60.6501
+ },
+ {
+ "cad_num": "66:41:0501001:6",
+ "address": "Екатеринбург, ул. Белинского, 200",
+ "area_ha": 0.91,
+ "status": "free",
+ "district": "Ленинский",
+ "vri": "multistory",
+ "lat": 56.8278,
+ "lon": 60.6398
+ },
+ {
+ "cad_num": "66:41:0601001:44",
+ "address": "Екатеринбург, ул. Надеждинская, 12",
+ "area_ha": 2.1,
+ "status": "free",
+ "district": "Кировский",
+ "vri": "multistory",
+ "lat": 56.8491,
+ "lon": 60.5781
+ },
+ {
+ "cad_num": "66:41:0602001:9",
+ "address": "Екатеринбург, ул. Тверитина, 38",
+ "area_ha": 0.72,
+ "status": "favorite",
+ "district": "Кировский",
+ "vri": "mixed",
+ "lat": 56.8532,
+ "lon": 60.5834
+ },
+ {
+ "cad_num": "66:41:0701001:18",
+ "address": "Екатеринбург, ул. Академика Постовского, 15",
+ "area_ha": 0.48,
+ "status": "free",
+ "district": "Октябрьский",
+ "vri": "multistory",
+ "lat": 56.8187,
+ "lon": 60.5921
+ },
+ {
+ "cad_num": "66:41:0702001:27",
+ "address": "Екатеринбург, ул. Начдива Онуфриева, 2а",
+ "area_ha": 3.4,
+ "status": "free",
+ "district": "Октябрьский",
+ "vri": "multistory",
+ "lat": 56.8112,
+ "lon": 60.5812
+ },
+ {
+ "cad_num": "66:41:0801001:55",
+ "address": "Екатеринбург, ул. Радищева, 33",
+ "area_ha": 0.61,
+ "status": "in_progress",
+ "district": "Ленинский",
+ "vri": "office",
+ "lat": 56.8348,
+ "lon": 60.6234
+ },
+ {
+ "cad_num": "66:41:0802001:11",
+ "address": "Екатеринбург, ул. Сакко и Ванцетти, 67",
+ "area_ha": 0.38,
+ "status": "free",
+ "district": "Ленинский",
+ "vri": "mixed",
+ "lat": 56.8363,
+ "lon": 60.6177
+ },
+ {
+ "cad_num": "66:41:0901001:7",
+ "address": "Екатеринбург, ул. Ботаническая, 11",
+ "area_ha": 1.54,
+ "status": "free",
+ "district": "Чкаловский",
+ "vri": "multistory",
+ "lat": 56.7892,
+ "lon": 60.6498
+ },
+ {
+ "cad_num": "66:41:0902001:22",
+ "address": "Екатеринбург, пр. Латвийский, 12",
+ "area_ha": 0.83,
+ "status": "favorite",
+ "district": "Чкаловский",
+ "vri": "multistory",
+ "lat": 56.7934,
+ "lon": 60.6541
+ },
+ {
+ "cad_num": "66:41:1001001:3",
+ "address": "Екатеринбург, ул. Краснолесья, 26",
+ "area_ha": 2.67,
+ "status": "free",
+ "district": "Октябрьский",
+ "vri": "multistory",
+ "lat": 56.7812,
+ "lon": 60.5698
+ },
+ {
+ "cad_num": "66:41:1002001:14",
+ "address": "Екатеринбург, ул. Ясная, 30",
+ "area_ha": 0.59,
+ "status": "in_progress",
+ "district": "Октябрьский",
+ "vri": "mixed",
+ "lat": 56.7856,
+ "lon": 60.5742
+ },
+ {
+ "cad_num": "66:41:1101001:42",
+ "address": "Екатеринбург, ул. Крестинского, 46",
+ "area_ha": 1.12,
+ "status": "free",
+ "district": "Верх-Исетский",
+ "vri": "multistory",
+ "lat": 56.8578,
+ "lon": 60.5634
+ },
+ {
+ "cad_num": "66:41:1102001:19",
+ "address": "Екатеринбург, ул. Новостроя, 8",
+ "area_ha": 0.76,
+ "status": "free",
+ "district": "Верх-Исетский",
+ "vri": "individual",
+ "lat": 56.8621,
+ "lon": 60.5589
+ }
+]
diff --git a/frontend/src/lib/site-finder-api.ts b/frontend/src/lib/site-finder-api.ts
new file mode 100644
index 00000000..1139e619
--- /dev/null
+++ b/frontend/src/lib/site-finder-api.ts
@@ -0,0 +1,207 @@
+/**
+ * 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)
+ */
+
+import { useQuery } from "@tanstack/react-query";
+import { apiFetch } from "@/lib/api";
+import { MOCK_PARCELS_BBOX, MOCK_RECENT_PARCELS } from "@/lib/mock-toggle";
+import fixtureParcels from "@/lib/mocks/parcels-bbox.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);
+
+ return apiFetch(
+ `/api/v1/parcels/by-bbox?${params.toString()}`,
+ );
+ },
+ 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,
+ });
+}