feat(osm): foundation — admin boundaries, POI catalog, sync orchestrator

This is the Phase 0 + Phase 1 + Phase 4 foundation of the full OSM
integration plan. It backfills three things the rest of the platform
has been faking with hardcoded tables, and gives admins one dashboard
for every OSM-sourced layer.

Phase 0 — Vietnam administrative boundaries
* New columns on vn_provinces / vn_districts / vn_wards: PostGIS
  geometry (MultiPolygon), centroid (Point), areaKm2, osmId, population,
  lastSyncedAt + GIST indexes on geometry/centroid.
* `scripts/sync-osm-admin-boundaries.ts` pulls
  `boundary=administrative + admin_level=4|6|8` from Overpass per chunk,
  filters to mainland VN via the existing country polygon, resolves the
  GSO code (or generates `OSM_<id>`), and upserts via raw SQL because
  Prisma can't manage PostGIS columns.
* `GeoLookupService` (shared module) replaces the old
  `nearestProvince()` heuristic — `lookup(lng,lat)` returns
  province/district/ward via `ST_Contains` on the GIST-indexed polygons.
* The KCN sync now resolves province/district from the polygon table
  and falls back to the centroid heuristic only when polygons aren't
  loaded yet.
* `scripts/backfill-admin-codes.ts` rewrites province/district/ward on
  IndustrialPark, ProjectDevelopment and Property using the new lookup.

Phase 1 — POI catalog (15 categories, schema only here)
* New `Poi` table with `PoiCategory` enum, OSM provenance columns,
  GIST index on `location`. New `TransportLine` for metro/highway
  multilinestrings.
* `scripts/sync-osm-poi.ts` queries Overpass per category × chunk,
  resolves province/district codes from the boundary polygons, upserts
  with `osmLocked` / `lockedFields` honour same as KCN.
* New NestJS `PoiModule` exposes:
    GET /poi/by-bbox    — GeoJSON for map overlays
    GET /poi/nearby     — sidebar "tiện ích xung quanh" (HMAC distance ranks)
    GET /poi/coverage   — admin per-category counts
* New web component `<NearbyPoiSidebar />` ready to drop into listing /
  project / KCN detail pages.

Phase 4 — Sync orchestrator + admin dashboard
* New `OsmSyncRun` audit table tracks every sync invocation
  (RUNNING / SUCCESS / PARTIAL / FAILED + row stats + error message).
* `OsmSyncService` spawns the right tsx script for any (layer, category,
  chunk) tuple, parses stats out of stdout, updates the run row.
* `OsmSyncCronService` schedules:
    Daily 02:00  → POI category rotation (1/day, 20-day cycle)
    Mon  02:30  → admin-boundaries provinces
    Wed  02:30  → admin-boundaries districts
    Sat  02:30  → admin-boundaries wards
    1st of month 03:00 → industrial-parks (per chunk)
  All gated by `OSM_SYNC_ENABLED=true`.
* New admin endpoints under `/admin/osm/*` (layers / coverage / runs /
  trigger), guarded by JWT + ADMIN role.
* New `/admin/osm` Next.js page: stat cards, coverage table with
  per-row "Sync now", recent runs list with auto-refresh every 15s.

Run on dev so far: 33 provinces + 1100+ districts (still finishing) +
305 hospitals POI imported.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-05-01 12:01:19 +07:00
parent 73ff469126
commit fba536406d
38 changed files with 3411 additions and 11 deletions

View File

@@ -0,0 +1,321 @@
'use client';
import {
AlertTriangle,
CheckCircle,
Clock,
Layers,
MapPin,
PlayCircle,
RefreshCw,
Train,
XCircle,
} from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
osmSyncApi,
type OsmCoverageSummary,
type OsmSyncLayer,
type OsmSyncRun,
} from '@/lib/osm-sync-api';
const STATUS_STYLES: Record<OsmSyncRun['status'], string> = {
RUNNING: 'bg-blue-100 text-blue-800 ring-blue-200',
SUCCESS: 'bg-emerald-100 text-emerald-800 ring-emerald-200',
PARTIAL: 'bg-amber-100 text-amber-800 ring-amber-200',
FAILED: 'bg-red-100 text-red-800 ring-red-200',
};
const STATUS_ICONS: Record<OsmSyncRun['status'], React.ReactNode> = {
RUNNING: <RefreshCw className="h-3 w-3 animate-spin" />,
SUCCESS: <CheckCircle className="h-3 w-3" />,
PARTIAL: <AlertTriangle className="h-3 w-3" />,
FAILED: <XCircle className="h-3 w-3" />,
};
function formatRelative(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
const diff = Date.now() - d.getTime();
if (diff < 60_000) return 'vừa xong';
if (diff < 3_600_000) return `${Math.round(diff / 60_000)} phút trước`;
if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)} giờ trước`;
return `${Math.round(diff / 86_400_000)} ngày trước`;
}
function formatDuration(start: string, end: string | null): string {
const startMs = new Date(start).getTime();
const endMs = end ? new Date(end).getTime() : Date.now();
const sec = Math.round((endMs - startMs) / 1000);
if (sec < 60) return `${sec}s`;
if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`;
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
}
export default function AdminOsmDashboardPage() {
const [coverage, setCoverage] = useState<OsmCoverageSummary | null>(null);
const [runs, setRuns] = useState<OsmSyncRun[]>([]);
const [layers, setLayers] = useState<OsmSyncLayer[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [triggering, setTriggering] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [cov, rs, ls] = await Promise.all([
osmSyncApi.coverage(),
osmSyncApi.runs({ limit: 30 }),
osmSyncApi.layers(),
]);
setCoverage(cov);
setRuns(rs);
setLayers(ls);
} catch (e) {
setError(e instanceof Error ? e.message : 'Lỗi tải dashboard');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
const int = setInterval(refresh, 15_000); // poll while RUNNING runs visible
return () => clearInterval(int);
}, [refresh]);
const trigger = async (layer: string, category?: string) => {
const key = `${layer}/${category ?? '-'}`;
setTriggering(key);
try {
await osmSyncApi.trigger({ layer, category });
await refresh();
} catch (e) {
setError(e instanceof Error ? e.message : 'Trigger fail');
} finally {
setTriggering(null);
}
};
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">OSM Sync Dashboard</h1>
<p className="text-sm text-muted-foreground">
Đng bộ OpenStreetMap Goodgo: ranh giới hành chính, POI, KCN, giao thông.
</p>
</div>
<Button variant="outline" size="sm" onClick={refresh} disabled={loading}>
<RefreshCw className={`mr-1.5 h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
Làm mới
</Button>
</div>
{error && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-2 text-sm text-destructive">
{error}
</div>
)}
{/* Top stats */}
{coverage && (
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
<StatCard
icon={<Layers className="h-4 w-4" />}
label="Đơn vị hành chính"
value={coverage.totals.administrativeUnits.toLocaleString('vi-VN')}
/>
<StatCard
icon={<MapPin className="h-4 w-4" />}
label="POI tổng"
value={coverage.totals.poiTotal.toLocaleString('vi-VN')}
/>
<StatCard
icon={<MapPin className="h-4 w-4 text-green-600" />}
label="KCN"
value={coverage.totals.industrialParks.toLocaleString('vi-VN')}
/>
<StatCard
icon={<Train className="h-4 w-4" />}
label="Bến/Ga"
value={coverage.totals.transportStations.toLocaleString('vi-VN')}
/>
<StatCard
icon={<Train className="h-4 w-4" />}
label="Tuyến giao thông"
value={coverage.totals.transportLines.toLocaleString('vi-VN')}
/>
</div>
)}
{/* Coverage table */}
<Card>
<CardContent className="p-0">
<div className="border-b border-border px-4 py-2.5">
<h2 className="text-sm font-semibold">Coverage theo layer</h2>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Layer / Category</TableHead>
<TableHead className="text-right">Tổng</TableHead>
<TableHead className="text-right">Promoted</TableHead>
<TableHead className="text-right">Raw</TableHead>
<TableHead>Sync gần nhất</TableHead>
<TableHead className="text-right">Hành đng</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{coverage?.rows.map((r) => {
const key = `${r.layer}/${r.category ?? '-'}`;
const layerDef = layers.find(
(l) => l.layer === r.layer && (l.category ?? null) === (r.category ?? null),
);
return (
<TableRow key={key}>
<TableCell>
<div className="font-medium">{r.layer}</div>
{r.category && (
<div className="text-xs text-muted-foreground">{r.category}</div>
)}
</TableCell>
<TableCell className="text-right font-mono">
{r.total.toLocaleString('vi-VN')}
{r.withGeometry !== undefined && r.withGeometry !== r.total && (
<span className="ml-1 text-xs text-muted-foreground">
({r.withGeometry} geom)
</span>
)}
</TableCell>
<TableCell className="text-right font-mono">
{r.promoted?.toLocaleString('vi-VN') ?? '—'}
</TableCell>
<TableCell className="text-right font-mono">
{r.raw?.toLocaleString('vi-VN') ?? '—'}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatRelative(r.lastSyncedAt)}
</TableCell>
<TableCell className="text-right">
{layerDef && (
<Button
variant="outline"
size="sm"
disabled={triggering === key}
onClick={() => trigger(r.layer, r.category ?? undefined)}
>
{triggering === key ? (
<RefreshCw className="mr-1 h-3 w-3 animate-spin" />
) : (
<PlayCircle className="mr-1 h-3 w-3" />
)}
Sync
</Button>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Recent runs */}
<Card>
<CardContent className="p-0">
<div className="border-b border-border px-4 py-2.5">
<h2 className="text-sm font-semibold">Sync runs gần đây</h2>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Layer</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Added</TableHead>
<TableHead className="text-right">Updated</TableHead>
<TableHead className="text-right">Skipped</TableHead>
<TableHead>Bắt đu</TableHead>
<TableHead>Thời gian</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-8 text-center text-sm text-muted-foreground">
Chưa sync run nào.
</TableCell>
</TableRow>
) : (
runs.map((r) => (
<TableRow key={r.id}>
<TableCell>
<div className="font-medium">{r.layer}</div>
{r.category && (
<div className="text-xs text-muted-foreground">{r.category}</div>
)}
</TableCell>
<TableCell>
<span
className={`inline-flex items-center gap-1 rounded-pill px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${STATUS_STYLES[r.status]}`}
>
{STATUS_ICONS[r.status]}
{r.status}
</span>
</TableCell>
<TableCell className="text-right font-mono">{r.rowsAdded}</TableCell>
<TableCell className="text-right font-mono">{r.rowsUpdated}</TableCell>
<TableCell className="text-right font-mono">{r.rowsSkipped}</TableCell>
<TableCell className="text-sm text-muted-foreground">
<Clock className="mr-1 inline h-3 w-3" />
{formatRelative(r.startedAt)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDuration(r.startedAt, r.finishedAt)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
function StatCard({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
{icon}
</div>
<div className="min-w-0">
<div className="truncate text-xs uppercase text-muted-foreground">{label}</div>
<div className="truncate text-lg font-semibold">{value}</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -7,6 +7,7 @@ import {
ShieldCheck,
Building2,
Factory,
Globe,
LogOut,
Menu,
Sparkles,
@@ -38,6 +39,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{ href: '/admin/accounts/developers' as const, label: 'Tài khoản CĐT', icon: Building2 },
{ href: '/admin/accounts/park-operators' as const, label: 'Tài khoản KCN', icon: Factory },
{ href: '/admin/industrial/osm-review' as const, label: 'Review OSM (KCN)', icon: Factory },
{ href: '/admin/osm' as const, label: 'OSM Sync Dashboard', icon: Globe },
{ href: '/admin/settings/ai' as const, label: t('adminNav.aiSettings'), icon: Sparkles },
];

View File

@@ -0,0 +1,151 @@
'use client';
import { Loader2, MapPin } from 'lucide-react';
import * as React from 'react';
import {
POI_ICONS,
POI_LABELS,
poiApi,
type NearbyPoiResult,
type PoiCategory,
} from '@/lib/poi-api';
interface Props {
/** Centre coordinates of the asset (listing / project / KCN). */
lat: number;
lng: number;
/** Search radius in metres. Default 1500m (~15 phút đi bộ). */
radius?: number;
/** Restrict to these categories. Default: 6 most relevant for residential. */
categories?: PoiCategory[];
/** N nearest POI shown per category. */
limitPerCategory?: number;
className?: string;
}
const DEFAULT_CATEGORIES: PoiCategory[] = [
'SCHOOL_PRIMARY',
'SCHOOL_SECONDARY',
'HOSPITAL',
'MARKET',
'BANK',
'METRO_STATION',
];
function formatDistance(m: number): string {
if (m < 1000) return `${m} m`;
return `${(m / 1000).toFixed(1)} km`;
}
/**
* Sidebar widget that lists the nearest POI of each category around a
* geo-tagged asset. Renders inside listing detail, project detail and KCN
* detail pages.
*/
export function NearbyPoiSidebar({
lat,
lng,
radius = 1500,
categories = DEFAULT_CATEGORIES,
limitPerCategory = 3,
className,
}: Props) {
const [data, setData] = React.useState<NearbyPoiResult | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
poiApi
.nearby({ lat, lng, radius, categories, limitPerCategory })
.then((res) => {
if (cancelled) return;
setData(res);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message ?? 'Không tải được tiện ích');
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [lat, lng, radius, categories, limitPerCategory]);
if (loading) {
return (
<div
className={`flex items-center gap-2 rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground ${className ?? ''}`}
>
<Loader2 className="h-4 w-4 animate-spin" />
Đang tải tiện ích xung quanh
</div>
);
}
if (error) {
return (
<div
className={`rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive ${className ?? ''}`}
>
{error}
</div>
);
}
if (!data || data.all.length === 0) {
return (
<div
className={`rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground ${className ?? ''}`}
>
Chưa dữ liệu tiện ích trong bán kính {formatDistance(radius)}.
</div>
);
}
return (
<div className={`rounded-lg border border-border bg-card ${className ?? ''}`}>
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
<h3 className="text-sm font-semibold text-foreground">Tiện ích xung quanh</h3>
<span className="text-xs text-muted-foreground">
{data.meta.totalCount} điểm · bán kính {formatDistance(data.meta.radiusMeters)}
</span>
</div>
<div className="flex flex-col divide-y divide-border">
{categories.map((cat) => {
const items = data.byCategory[cat] ?? [];
if (items.length === 0) return null;
return (
<div key={cat} className="px-4 py-2.5">
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium uppercase text-muted-foreground">
<span aria-hidden>{POI_ICONS[cat]}</span>
{POI_LABELS[cat]}
</div>
<ul className="flex flex-col gap-1.5">
{items.map((p) => (
<li key={p.id} className="flex items-start gap-2 text-sm">
<MapPin className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">{p.name}</div>
{p.address && (
<div className="truncate text-xs text-muted-foreground">{p.address}</div>
)}
</div>
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
{formatDistance(p.distanceM)}
</span>
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { apiClient } from './api-client';
export interface OsmCoverageRow {
layer: string;
category: string | null;
total: number;
withGeometry?: number;
promoted?: number;
raw?: number;
lastSyncedAt: string | null;
}
export interface OsmCoverageSummary {
rows: OsmCoverageRow[];
totals: {
administrativeUnits: number;
poiTotal: number;
industrialParks: number;
transportStations: number;
transportLines: number;
};
}
export interface OsmSyncRun {
id: string;
layer: string;
category: string | null;
chunk: string | null;
startedAt: string;
finishedAt: string | null;
status: 'RUNNING' | 'SUCCESS' | 'PARTIAL' | 'FAILED';
rowsAdded: number;
rowsUpdated: number;
rowsSkipped: number;
rowsLocked: number;
errorMessage: string | null;
}
export interface OsmSyncLayer {
layer: string;
category?: string;
weight: 'light' | 'medium' | 'heavy';
}
export const osmSyncApi = {
layers: () => apiClient.get<OsmSyncLayer[]>('/admin/osm/layers'),
coverage: () => apiClient.get<OsmCoverageSummary>('/admin/osm/coverage'),
runs: (params: { layer?: string; status?: string; limit?: number } = {}) => {
const q = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== '') q.append(k, String(v));
});
const qs = q.toString();
return apiClient.get<OsmSyncRun[]>(`/admin/osm/runs${qs ? `?${qs}` : ''}`);
},
trigger: (body: { layer: string; category?: string; chunk?: string }) =>
apiClient.post<{ runId: string; status: string }>('/admin/osm/runs', body),
};

140
apps/web/lib/poi-api.ts Normal file
View File

@@ -0,0 +1,140 @@
import { apiClient } from './api-client';
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
export type PoiCategory =
| 'SCHOOL_PRIMARY' | 'SCHOOL_SECONDARY' | 'UNIVERSITY'
| 'HOSPITAL' | 'CLINIC' | 'PHARMACY'
| 'MARKET' | 'SUPERMARKET' | 'MALL' | 'CONVENIENCE'
| 'BANK' | 'ATM'
| 'PARK'
| 'GAS_STATION' | 'POLICE' | 'POST_OFFICE'
| 'METRO_STATION' | 'RAILWAY_STATION' | 'BUS_STATION' | 'AIRPORT';
/** Vietnamese display labels for each POI category. */
export const POI_LABELS: Record<PoiCategory, string> = {
SCHOOL_PRIMARY: 'Trường tiểu học',
SCHOOL_SECONDARY: 'Trường THCS / THPT',
UNIVERSITY: 'Đại học / Cao đẳng',
HOSPITAL: 'Bệnh viện',
CLINIC: 'Phòng khám',
PHARMACY: 'Nhà thuốc',
MARKET: 'Chợ',
SUPERMARKET: 'Siêu thị',
MALL: 'TTTM',
CONVENIENCE: 'Cửa hàng tiện lợi',
BANK: 'Ngân hàng',
ATM: 'ATM',
PARK: 'Công viên',
GAS_STATION: 'Cây xăng',
POLICE: 'Công an',
POST_OFFICE: 'Bưu điện',
METRO_STATION: 'Ga Metro',
RAILWAY_STATION: 'Ga tàu',
BUS_STATION: 'Bến xe',
AIRPORT: 'Sân bay',
};
/** Single-emoji icon for chips / map markers (no extra image dep needed). */
export const POI_ICONS: Record<PoiCategory, string> = {
SCHOOL_PRIMARY: '🏫', SCHOOL_SECONDARY: '🎒', UNIVERSITY: '🎓',
HOSPITAL: '🏥', CLINIC: '⚕️', PHARMACY: '💊',
MARKET: '🛒', SUPERMARKET: '🏪', MALL: '🛍️', CONVENIENCE: '🏬',
BANK: '🏦', ATM: '🏧',
PARK: '🌳',
GAS_STATION: '⛽', POLICE: '👮', POST_OFFICE: '📮',
METRO_STATION: '🚇', RAILWAY_STATION: '🚉', BUS_STATION: '🚌', AIRPORT: '✈️',
};
/** Tailwind colour class per category — keep marker coding consistent. */
export const POI_COLORS: Record<PoiCategory, string> = {
SCHOOL_PRIMARY: '#3b82f6', SCHOOL_SECONDARY: '#2563eb', UNIVERSITY: '#1d4ed8',
HOSPITAL: '#ef4444', CLINIC: '#f87171', PHARMACY: '#fb7185',
MARKET: '#f59e0b', SUPERMARKET: '#fbbf24', MALL: '#fcd34d', CONVENIENCE: '#fde68a',
BANK: '#8b5cf6', ATM: '#a78bfa',
PARK: '#22c55e',
GAS_STATION: '#64748b', POLICE: '#0f172a', POST_OFFICE: '#be185d',
METRO_STATION: '#0ea5e9', RAILWAY_STATION: '#0284c7', BUS_STATION: '#0369a1', AIRPORT: '#075985',
};
export interface NearbyPoi {
id: string;
name: string;
category: PoiCategory;
distanceM: number;
lat: number;
lng: number;
address: string | null;
}
export interface NearbyPoiResult {
byCategory: Partial<Record<PoiCategory, NearbyPoi[]>>;
all: NearbyPoi[];
meta: { radiusMeters: number; totalCount: number; requestedCategories: PoiCategory[] | null };
}
export interface PoiBboxFeatureCollection {
type: 'FeatureCollection';
features: {
type: 'Feature';
id: string;
geometry: { type: 'Point'; coordinates: [number, number] };
properties: {
id: string;
name: string;
category: PoiCategory;
provinceCode: string | null;
districtCode: string | null;
};
}[];
meta: { count: number; truncated: boolean; categories: PoiCategory[] };
}
/* -------------------------------------------------------------------------- */
/* API */
/* -------------------------------------------------------------------------- */
export const poiApi = {
/**
* Fetch nearest N POI (per category) within `radius` metres of the given
* point. Drives the "tiện ích xung quanh" sidebar.
*/
nearby: (params: {
lat: number;
lng: number;
radius: number;
categories?: PoiCategory[];
limitPerCategory?: number;
}): Promise<NearbyPoiResult> => {
const q = new URLSearchParams({
lat: String(params.lat),
lng: String(params.lng),
radius: String(params.radius),
});
if (params.categories?.length) q.set('categories', params.categories.join(','));
if (params.limitPerCategory) q.set('limitPerCategory', String(params.limitPerCategory));
return apiClient.get<NearbyPoiResult>(`/poi/nearby?${q.toString()}`);
},
/** GeoJSON for map overlays. Used by the listing detail mini-map and KCN page. */
byBbox: (params: {
south: number;
west: number;
north: number;
east: number;
categories?: PoiCategory[];
limit?: number;
}): Promise<PoiBboxFeatureCollection> => {
const q = new URLSearchParams({
south: String(params.south),
west: String(params.west),
north: String(params.north),
east: String(params.east),
});
if (params.categories?.length) q.set('categories', params.categories.join(','));
if (params.limit) q.set('limit', String(params.limit));
return apiClient.get<PoiBboxFeatureCollection>(`/poi/by-bbox?${q.toString()}`);
},
};