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:
Ho Ngoc Hai
2026-05-01 12:06:52 +07:00
parent fba536406d
commit a9770a5f93
4 changed files with 286 additions and 0 deletions

View File

@@ -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 */}
<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 */}
<Card>
<CardHeader>

View File

@@ -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 && (
<NearbyPoiSidebar
lat={property.latitude}
lng={property.longitude}
radius={1500}
/>
)}
{/* Stats */}
<Card>
<CardContent className="pt-5">

View 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>
);
}