'use client'; /* eslint-disable import-x/no-named-as-default-member */ import mapboxgl from 'mapbox-gl'; import { MapPin, Search, X } from 'lucide-react'; import * as React from 'react'; import 'mapbox-gl/dist/mapbox-gl.css'; import { useMapboxStyle } from '@/lib/mapbox-style'; import { cn } from '@/lib/utils'; /** * Lightweight Mapbox-based location picker. Click the map (or drag the * marker) to set lat/lng; optional search box geocodes via Mapbox Places * (Vietnam-scoped). Emits both the new coords and, when available, the * parsed address components from the geocoder feature context — consumers * can hydrate address/ward/district/city fields without extra typing. */ export interface ResolvedAddress { address?: string; ward?: string; district?: string; city?: string; } interface LocationPickerProps { lat?: number | null; lng?: number | null; onChange: (coords: { lat: number; lng: number }, resolved?: ResolvedAddress) => void; height?: string; className?: string; } const DEFAULT_CENTER: [number, number] = [106.7009, 10.7769]; // HCMC const DEFAULT_ZOOM = 12; const PICKED_ZOOM = 15; interface MapboxFeature { id: string; place_name: string; text: string; center: [number, number]; place_type?: string[]; context?: Array<{ id: string; text: string }>; } function parseContext(feature: MapboxFeature): ResolvedAddress { const ctx = feature.context ?? []; const out: ResolvedAddress = {}; // `feature.text` is the matched leaf (street / POI / locality). out.address = feature.place_name; for (const c of ctx) { if (c.id.startsWith('locality')) out.ward = c.text; else if (c.id.startsWith('district')) out.district = c.text; else if (c.id.startsWith('place')) { // Mapbox uses "place" for cities in VN out.city = c.text; } else if (c.id.startsWith('region') && !out.city) { out.city = c.text; } } return out; } export function LocationPicker({ lat, lng, onChange, height = '320px', className, }: LocationPickerProps) { const mapContainerRef = React.useRef(null); const mapRef = React.useRef(null); const markerRef = React.useRef(null); const onChangeRef = React.useRef(onChange); const mapStyle = useMapboxStyle(); // Keep the latest onChange in a ref so our marker/map listeners don't // trigger a re-initialisation every render. React.useEffect(() => { onChangeRef.current = onChange; }, [onChange]); const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; // Init map + marker once. React.useEffect(() => { if (!mapContainerRef.current || !token) return; mapboxgl.accessToken = token; const initial: [number, number] = typeof lat === 'number' && typeof lng === 'number' ? [lng, lat] : DEFAULT_CENTER; const initialZoom = typeof lat === 'number' && typeof lng === 'number' ? PICKED_ZOOM : DEFAULT_ZOOM; const map = new mapboxgl.Map({ container: mapContainerRef.current, style: mapStyle, center: initial, zoom: initialZoom, attributionControl: false, }); map.addControl(new mapboxgl.NavigationControl(), 'top-right'); map.addControl(new mapboxgl.AttributionControl({ compact: true }), 'bottom-right'); // Draggable primary marker. const markerEl = document.createElement('div'); markerEl.style.cssText = `width: 32px; height: 32px; cursor: grab;`; const pin = document.createElement('div'); pin.style.cssText = ` width: 100%; height: 100%; border-radius: 50% 50% 50% 0; background: hsl(var(--primary)); border: 3px solid hsl(var(--card)); box-shadow: 0 2px 6px rgba(0,0,0,0.35); transform: rotate(-45deg); pointer-events: none; `; markerEl.appendChild(pin); const marker = new mapboxgl.Marker({ element: markerEl, draggable: true, anchor: 'bottom' }) .setLngLat(initial) .addTo(map); marker.on('dragend', () => { const lngLat = marker.getLngLat(); onChangeRef.current({ lat: lngLat.lat, lng: lngLat.lng }); }); map.on('click', (e) => { marker.setLngLat(e.lngLat); onChangeRef.current({ lat: e.lngLat.lat, lng: e.lngLat.lng }); }); mapRef.current = map; markerRef.current = marker; return () => { marker.remove(); map.remove(); mapRef.current = null; markerRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Sync theme. React.useEffect(() => { mapRef.current?.setStyle(mapStyle); }, [mapStyle]); // When parent updates lat/lng externally (e.g. hydrating the edit form), // fly the map and move the marker. React.useEffect(() => { const map = mapRef.current; const marker = markerRef.current; if (!map || !marker) return; if (typeof lat !== 'number' || typeof lng !== 'number') return; const current = marker.getLngLat(); if (Math.abs(current.lat - lat) < 1e-7 && Math.abs(current.lng - lng) < 1e-7) return; marker.setLngLat([lng, lat]); map.flyTo({ center: [lng, lat], zoom: Math.max(map.getZoom(), PICKED_ZOOM - 1) }); }, [lat, lng]); // Geocoding search. const [query, setQuery] = React.useState(''); const [results, setResults] = React.useState([]); const [searching, setSearching] = React.useState(false); const abortRef = React.useRef(null); React.useEffect(() => { if (!token) return; const trimmed = query.trim(); if (trimmed.length < 3) { setResults([]); return; } const handle = setTimeout(async () => { abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; setSearching(true); try { const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(trimmed)}.json` + `?access_token=${token}&country=vn&language=vi&limit=5`; const res = await fetch(url, { signal: ctrl.signal }); if (!res.ok) throw new Error(String(res.status)); const body = (await res.json()) as { features?: MapboxFeature[] }; setResults(body.features ?? []); } catch { if (!ctrl.signal.aborted) setResults([]); } finally { if (!ctrl.signal.aborted) setSearching(false); } }, 350); return () => clearTimeout(handle); }, [query, token]); const pickResult = (feature: MapboxFeature) => { const [fLng, fLat] = feature.center; markerRef.current?.setLngLat([fLng, fLat]); mapRef.current?.flyTo({ center: [fLng, fLat], zoom: PICKED_ZOOM }); onChangeRef.current({ lat: fLat, lng: fLng }, parseContext(feature)); setQuery(''); setResults([]); }; if (!token) { return (
Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để chọn vị trí trên bản đồ
); } return (
{/* Search box */}
{results.length > 0 && (
    {results.map((f) => (
  • ))}
)} {searching && query.trim().length >= 3 && results.length === 0 && (

Đang tìm...

)}
{/* Hint badge */}
Nhấp vào bản đồ hoặc kéo pin để chọn vị trí
); }