diff --git a/apps/web/components/khu-cong-nghiep/khu-cong-nghiep-detail-client.tsx b/apps/web/components/khu-cong-nghiep/khu-cong-nghiep-detail-client.tsx index 7a688c3..7b96295 100644 --- a/apps/web/components/khu-cong-nghiep/khu-cong-nghiep-detail-client.tsx +++ b/apps/web/components/khu-cong-nghiep/khu-cong-nghiep-detail-client.tsx @@ -14,6 +14,7 @@ import { } from 'lucide-react'; import * as React from 'react'; import { ParkMap } from '@/components/khu-cong-nghiep/park-map'; +import { NearbyPoiSidebar } from '@/components/poi/nearby-poi-sidebar'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { @@ -252,6 +253,16 @@ export function KhuCongNghiepDetailClient({ park }: KhuCongNghiepDetailClientPro {/* Sidebar */}
+ {/* OSM POI nearby (schools, hospitals, banks, transport, …) */} + {park.latitude != null && park.longitude != null && ( + + )} + {/* Rent info */} diff --git a/apps/web/components/listings/listing-detail-client.tsx b/apps/web/components/listings/listing-detail-client.tsx index 9ca0b56..11b200a 100644 --- a/apps/web/components/listings/listing-detail-client.tsx +++ b/apps/web/components/listings/listing-detail-client.tsx @@ -6,6 +6,7 @@ import * as React from 'react'; import { AddToCompareButton } from '@/components/comparison/add-to-compare-button'; import { AiAdviceCards } from '@/components/listings/ai-advice-cards'; import { ImageGallery } from '@/components/listings/image-gallery'; +import { NearbyPoiSidebar } from '@/components/poi/nearby-poi-sidebar'; import { InquiryModal } from '@/components/listings/inquiry-modal'; import { PriceHistoryChart } from '@/components/listings/price-history-chart'; import { ReportListingModal } from '@/components/listings/report-listing-modal'; @@ -897,6 +898,15 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) { onOpenChange={setReportOpen} /> + {/* OSM POI nearby — schools, hospitals, markets, banks, metro… */} + {property.latitude != null && property.longitude != null && ( + + )} + {/* Stats */} diff --git a/apps/web/components/poi/poi-search-filter.tsx b/apps/web/components/poi/poi-search-filter.tsx new file mode 100644 index 0000000..37b16ab --- /dev/null +++ b/apps/web/components/poi/poi-search-filter.tsx @@ -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(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 ( +
+ + + {open && ( +
+ {/* Radius */} +
+ + +
+ + {/* Quick presets */} +
+
Gợi ý
+
+ {QUICK_PRESETS.map((p) => ( + + ))} + {value.categories.length > 0 && ( + + )} +
+
+ + {/* Category groups */} +
+ {ALL_GROUPS.map((g) => ( +
+
+ {g.label} +
+
+ {g.items.map((cat) => { + const checked = value.categories.includes(cat); + return ( + + ); + })} +
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/docs/osm-data-model.md b/docs/osm-data-model.md new file mode 100644 index 0000000..80fa49d --- /dev/null +++ b/docs/osm-data-model.md @@ -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 |