feat(osm): user-facing UX — POI sidebar + search filter + docs
* Listing detail: drop the new <NearbyPoiSidebar> below the price card
with default 1.5km radius and 6 categories (school/secondary/hospital/
market/bank/metro). Reads property.lat/lng — no-op when unset.
* KCN detail: same component but 3km radius with the categories that
matter for industrial parks (hospital/bank/gas/bus/metro/police).
* New <PoiSearchFilter> widget for the search page: pill button →
popover with radius dropdown (300m..5km), 3 quick presets ("Family",
"Commute", "Convenience"), and 6 grouped category checkboxes. Wires
to a `PoiNearbyConstraint` value so callers can pass it into search
filters when they're ready.
* docs/osm-data-model.md: canonical reference for every OSM-sourced
table, sync cadence, quality gates, runbook for ops, and a clear
"how to add a new POI category" guide.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ParkMap } from '@/components/khu-cong-nghiep/park-map';
|
import { ParkMap } from '@/components/khu-cong-nghiep/park-map';
|
||||||
|
import { NearbyPoiSidebar } from '@/components/poi/nearby-poi-sidebar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
@@ -252,6 +253,16 @@ export function KhuCongNghiepDetailClient({ park }: KhuCongNghiepDetailClientPro
|
|||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* OSM POI nearby (schools, hospitals, banks, transport, …) */}
|
||||||
|
{park.latitude != null && park.longitude != null && (
|
||||||
|
<NearbyPoiSidebar
|
||||||
|
lat={park.latitude}
|
||||||
|
lng={park.longitude}
|
||||||
|
radius={3000}
|
||||||
|
categories={['HOSPITAL', 'BANK', 'GAS_STATION', 'BUS_STATION', 'METRO_STATION', 'POLICE']}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Rent info */}
|
{/* Rent info */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import * as React from 'react';
|
|||||||
import { AddToCompareButton } from '@/components/comparison/add-to-compare-button';
|
import { AddToCompareButton } from '@/components/comparison/add-to-compare-button';
|
||||||
import { AiAdviceCards } from '@/components/listings/ai-advice-cards';
|
import { AiAdviceCards } from '@/components/listings/ai-advice-cards';
|
||||||
import { ImageGallery } from '@/components/listings/image-gallery';
|
import { ImageGallery } from '@/components/listings/image-gallery';
|
||||||
|
import { NearbyPoiSidebar } from '@/components/poi/nearby-poi-sidebar';
|
||||||
import { InquiryModal } from '@/components/listings/inquiry-modal';
|
import { InquiryModal } from '@/components/listings/inquiry-modal';
|
||||||
import { PriceHistoryChart } from '@/components/listings/price-history-chart';
|
import { PriceHistoryChart } from '@/components/listings/price-history-chart';
|
||||||
import { ReportListingModal } from '@/components/listings/report-listing-modal';
|
import { ReportListingModal } from '@/components/listings/report-listing-modal';
|
||||||
@@ -897,6 +898,15 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
|
|||||||
onOpenChange={setReportOpen}
|
onOpenChange={setReportOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* OSM POI nearby — schools, hospitals, markets, banks, metro… */}
|
||||||
|
{property.latitude != null && property.longitude != null && (
|
||||||
|
<NearbyPoiSidebar
|
||||||
|
lat={property.latitude}
|
||||||
|
lng={property.longitude}
|
||||||
|
radius={1500}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-5">
|
<CardContent className="pt-5">
|
||||||
|
|||||||
173
apps/web/components/poi/poi-search-filter.tsx
Normal file
173
apps/web/components/poi/poi-search-filter.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { POI_ICONS, POI_LABELS, type PoiCategory } from '@/lib/poi-api';
|
||||||
|
|
||||||
|
export interface PoiNearbyConstraint {
|
||||||
|
/** Required POI categories — listing must have at least one of each within radius. */
|
||||||
|
categories: PoiCategory[];
|
||||||
|
/** Radius in metres (50 - 5000). */
|
||||||
|
radiusM: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: PoiNearbyConstraint;
|
||||||
|
onChange: (next: PoiNearbyConstraint) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RADIUS_OPTIONS = [
|
||||||
|
{ value: 300, label: '300m (đi bộ 5 phút)' },
|
||||||
|
{ value: 500, label: '500m' },
|
||||||
|
{ value: 1000, label: '1 km' },
|
||||||
|
{ value: 1500, label: '1.5 km' },
|
||||||
|
{ value: 2500, label: '2.5 km' },
|
||||||
|
{ value: 5000, label: '5 km (xe máy 10 phút)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const QUICK_PRESETS: { label: string; categories: PoiCategory[] }[] = [
|
||||||
|
{ label: 'Gia đình con nhỏ', categories: ['SCHOOL_PRIMARY', 'HOSPITAL', 'MARKET'] },
|
||||||
|
{ label: 'Đi làm văn phòng', categories: ['BUS_STATION', 'METRO_STATION', 'BANK'] },
|
||||||
|
{ label: 'Tiện nghi', categories: ['SUPERMARKET', 'PARK', 'PHARMACY'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL_GROUPS: { label: string; items: PoiCategory[] }[] = [
|
||||||
|
{ label: 'Giáo dục', items: ['SCHOOL_PRIMARY', 'SCHOOL_SECONDARY', 'UNIVERSITY'] },
|
||||||
|
{ label: 'Y tế', items: ['HOSPITAL', 'CLINIC', 'PHARMACY'] },
|
||||||
|
{ label: 'Thương mại', items: ['MARKET', 'SUPERMARKET', 'MALL', 'CONVENIENCE'] },
|
||||||
|
{ label: 'Tài chính', items: ['BANK', 'ATM'] },
|
||||||
|
{ label: 'Giao thông', items: ['METRO_STATION', 'BUS_STATION', 'RAILWAY_STATION', 'AIRPORT'] },
|
||||||
|
{ label: 'Khác', items: ['PARK', 'GAS_STATION', 'POLICE', 'POST_OFFICE'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact filter widget for the search page: pick "in X meters" + which
|
||||||
|
* POI categories are required nearby. Designed to slot into an existing
|
||||||
|
* search filter bar — see `apps/web/app/[locale]/(public)/search/page.tsx`.
|
||||||
|
*/
|
||||||
|
export function PoiSearchFilter({ value, onChange, className }: Props) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const ref = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onClick);
|
||||||
|
return () => document.removeEventListener('mousedown', onClick);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const toggle = (cat: PoiCategory) => {
|
||||||
|
const next = value.categories.includes(cat)
|
||||||
|
? value.categories.filter((c) => c !== cat)
|
||||||
|
: [...value.categories, cat];
|
||||||
|
onChange({ ...value, categories: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary =
|
||||||
|
value.categories.length === 0
|
||||||
|
? 'Tiện ích xung quanh'
|
||||||
|
: `${value.categories.length} tiện ích · ${value.radiusM >= 1000 ? `${value.radiusM / 1000}km` : `${value.radiusM}m`}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={`relative ${className ?? ''}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex h-9 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm text-foreground transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<span>{summary}</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-3.5 w-3.5 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-full z-popover mt-2 w-[28rem] max-w-[90vw] overflow-hidden rounded-lg border border-border bg-card shadow-elevation-3">
|
||||||
|
{/* Radius */}
|
||||||
|
<div className="border-b border-border p-3">
|
||||||
|
<label className="mb-1 block text-xs font-medium text-muted-foreground">
|
||||||
|
Trong bán kính
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={value.radiusM}
|
||||||
|
onChange={(e) => onChange({ ...value, radiusM: Number(e.target.value) })}
|
||||||
|
className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||||
|
>
|
||||||
|
{RADIUS_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick presets */}
|
||||||
|
<div className="border-b border-border p-3">
|
||||||
|
<div className="mb-1.5 text-xs font-medium text-muted-foreground">Gợi ý</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{QUICK_PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ ...value, categories: p.categories })}
|
||||||
|
className="rounded-full border border-border bg-background px-2.5 py-1 text-xs transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{value.categories.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ ...value, categories: [] })}
|
||||||
|
className="rounded-full border border-destructive/30 bg-destructive/10 px-2.5 py-1 text-xs text-destructive transition-colors hover:bg-destructive/15"
|
||||||
|
>
|
||||||
|
Bỏ chọn
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category groups */}
|
||||||
|
<div className="max-h-72 overflow-y-auto p-3">
|
||||||
|
{ALL_GROUPS.map((g) => (
|
||||||
|
<div key={g.label} className="mb-2 last:mb-0">
|
||||||
|
<div className="mb-1 text-xs font-medium uppercase text-muted-foreground">
|
||||||
|
{g.label}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{g.items.map((cat) => {
|
||||||
|
const checked = value.categories.includes(cat);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={cat}
|
||||||
|
className={`flex cursor-pointer items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors ${
|
||||||
|
checked
|
||||||
|
? 'border-primary bg-primary/10 text-primary'
|
||||||
|
: 'border-border bg-background hover:bg-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggle(cat)}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
<span aria-hidden>{POI_ICONS[cat]}</span>
|
||||||
|
<span className="truncate">{POI_LABELS[cat]}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
docs/osm-data-model.md
Normal file
92
docs/osm-data-model.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# OSM Data Model — GoodGo Platform
|
||||||
|
|
||||||
|
This document is the canonical reference for every OpenStreetMap-sourced
|
||||||
|
table in the GoodGo database, the sync pipelines that populate them, and
|
||||||
|
the query patterns that use them.
|
||||||
|
|
||||||
|
## Tables at a glance
|
||||||
|
|
||||||
|
| Table | Source | Geometry | Sync cadence | Used by |
|
||||||
|
|-------|--------|----------|--------------|---------|
|
||||||
|
| `vn_provinces` | OSM `boundary=administrative + admin_level=4` | MultiPolygon | Weekly (Mon 02:30 ICT) | `GeoLookupService`, KCN sync, address auto-fill |
|
||||||
|
| `vn_districts` | OSM `admin_level=6` | MultiPolygon | Weekly (Wed 02:30 ICT) | Same as above |
|
||||||
|
| `vn_wards` | OSM `admin_level=8` | MultiPolygon | Weekly (Sat 02:30 ICT) | Same as above |
|
||||||
|
| `Poi` | OSM nodes/ways/relations matching 20 category selectors | Point | Daily 1 category rotation (02:00 ICT) | `/poi/nearby`, `/poi/by-bbox`, listing sidebar, search filter |
|
||||||
|
| `TransportLine` | OSM `route=subway|train|highway` relations | MultiLineString | Monthly | Distance scoring, planned for Phase 2 UX |
|
||||||
|
| `IndustrialPark` | OSM `landuse=industrial` ways/relations | Point + MultiPolygon boundary | Monthly (1st 03:00 ICT, 4 chunks) | `/industrial/parks/*`, KCN catalog |
|
||||||
|
| `OsmSyncRun` | Generated by orchestrator | — | Append-only audit | `/admin/osm` dashboard |
|
||||||
|
|
||||||
|
All sync writes are gated by `OSM_SYNC_ENABLED=true` so dev / staging
|
||||||
|
environments don't hit Overpass accidentally.
|
||||||
|
|
||||||
|
## GeoLookupService — the foundation
|
||||||
|
|
||||||
|
Every other layer depends on `vn_provinces.geometry` for PostGIS
|
||||||
|
`ST_Contains` lookups. The service exposes:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const r = await geo.lookup(lng, lat);
|
||||||
|
// → { province: { code, name }, district: { code, name }, ward: { code, name } }
|
||||||
|
|
||||||
|
const inside = await geo.isInVietnam(lng, lat);
|
||||||
|
// → boolean
|
||||||
|
|
||||||
|
const cov = await geo.coverage();
|
||||||
|
// → { provinces: { total, withGeometry, lastSyncedAt }, districts: ..., wards: ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
It replaces the old `nearestProvince()` heuristic that walked a
|
||||||
|
hardcoded centroid table.
|
||||||
|
|
||||||
|
## Quality gates baked into sync scripts
|
||||||
|
|
||||||
|
1. **Geographic gate** — `isPointInVietnam(lng, lat)` from
|
||||||
|
`scripts/data/vn-country-polygon.ts` rejects rows whose centroid
|
||||||
|
falls outside the VN mainland polygon (catches China / Laos /
|
||||||
|
Cambodia bleed across the Overpass bbox chunks).
|
||||||
|
2. **Name gate** — rows whose `name` contains zero Latin/Vietnamese
|
||||||
|
letters (`/[A-Za-zÀ-ỹ]/`) are dropped (filters CJK / Khmer / Thai).
|
||||||
|
3. **Lock gate** — when an admin sets `osmLocked=true` or adds a column
|
||||||
|
to `lockedFields`, the next sync skips that row entirely (or that
|
||||||
|
column) so manual edits survive.
|
||||||
|
|
||||||
|
## Adding a new POI category
|
||||||
|
|
||||||
|
1. Add the enum value to `PoiCategory` in `prisma/schema.prisma` and
|
||||||
|
create a Prisma migration that `ALTER TYPE "PoiCategory" ADD VALUE`.
|
||||||
|
2. Add the Overpass selector to `CATEGORY_QUERIES` in
|
||||||
|
`scripts/sync-osm-poi.ts`.
|
||||||
|
3. Append the same enum value to the `POI_CATEGORIES` rotation list in
|
||||||
|
`OsmSyncCronService` so the cron picks it up.
|
||||||
|
4. Add labels + icons + colour to `apps/web/lib/poi-api.ts` so the UI
|
||||||
|
chips render.
|
||||||
|
|
||||||
|
That's it — `OsmSyncService.findLayer('poi', 'YOUR_CAT')` will return a
|
||||||
|
def automatically because `SYNC_LAYERS` is generated from the enum keys.
|
||||||
|
|
||||||
|
## Operational runbook
|
||||||
|
|
||||||
|
* **Sync hangs / 504 from Overpass** — `kubectl describe pod` on the
|
||||||
|
Kaniko-style sync runner shows the chunk in flight. The script has
|
||||||
|
a 5× retry on the clone step (HTTP 504 from Gitea is transient).
|
||||||
|
For Overpass itself, raise the per-script `[out:json][timeout:N]`
|
||||||
|
by editing the script. Default 180s for POI, 300s for boundaries.
|
||||||
|
* **Runs stuck in `RUNNING` state** — `OsmSyncOrchestrator` writes the
|
||||||
|
row before spawning the script. If the script process dies without
|
||||||
|
emitting an `exit` event, the row stays RUNNING. Mitigation: cron
|
||||||
|
job to flip RUNNING > 6h old to FAILED with `errorMessage='timeout'`.
|
||||||
|
* **Conflict logs** — when sync updates a column the admin had locked,
|
||||||
|
it skips the column silently. There is no separate conflict table
|
||||||
|
(yet). To audit, search Loki for `[osm-sync] skipping locked field`.
|
||||||
|
|
||||||
|
## Phase status
|
||||||
|
|
||||||
|
| Phase | Status | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| 0 — Admin boundaries + GeoLookupService | ✅ Schema, sync, service done. Provinces synced (33), districts in progress |
|
||||||
|
| 1 — POI catalog + sync | ✅ Schema + sync script + NestJS module + sidebar component done. Hospital category synced (~500 rows) |
|
||||||
|
| 2 — Transport (metro/railway/airport) | 🟡 Stations synced via POI; lines layer pending |
|
||||||
|
| 3 — Buildings / landuse | ⏳ Deferred — admin says low priority |
|
||||||
|
| 4 — Sync orchestrator + admin dashboard | ✅ Service + cron + Prometheus-friendly stats + admin UI done |
|
||||||
|
| 5 — User-facing UX | 🟡 Listing + KCN sidebar wired; search filter widget built; map overlays pending |
|
||||||
|
| 6 — Performance hardening | ⏳ Materialized views + Redis cache pending |
|
||||||
Reference in New Issue
Block a user