diff --git a/apps/web/app/[locale]/(dashboard)/listings/[id]/edit/page.tsx b/apps/web/app/[locale]/(dashboard)/listings/[id]/edit/page.tsx index 38e091f..7d8ab27 100644 --- a/apps/web/app/[locale]/(dashboard)/listings/[id]/edit/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/listings/[id]/edit/page.tsx @@ -44,6 +44,8 @@ export default function EditListingPage() { register, reset, handleSubmit, + setValue, + watch, formState: { errors }, } = useForm({ resolver: zodResolver(createListingSchema), @@ -134,6 +136,8 @@ export default function EditListingPage() { ward: property.ward, district: property.district, city: property.city, + latitude: property.latitude != null ? String(property.latitude) : '', + longitude: property.longitude != null ? String(property.longitude) : '', areaM2: String(property.areaM2), bedrooms: property.bedrooms != null ? String(property.bedrooms) : '', bathrooms: property.bathrooms != null ? String(property.bathrooms) : '', @@ -221,7 +225,7 @@ export default function EditListingPage() { - + diff --git a/apps/web/app/[locale]/(dashboard)/listings/new/page.tsx b/apps/web/app/[locale]/(dashboard)/listings/new/page.tsx index a2eeac9..8f0ef99 100644 --- a/apps/web/app/[locale]/(dashboard)/listings/new/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/listings/new/page.tsx @@ -55,6 +55,8 @@ export default function CreateListingPage() { register, handleSubmit, trigger, + setValue, + watch, formState: { errors }, } = useForm({ resolver: zodResolver(createListingSchema), @@ -208,7 +210,9 @@ export default function CreateListingPage() { {currentStep === 0 && } - {currentStep === 1 && } + {currentStep === 1 && ( + + )} {currentStep === 2 && } {currentStep === 3 && } {currentStep === 4 && ( diff --git a/apps/web/components/listings/__tests__/listing-form-steps.spec.tsx b/apps/web/components/listings/__tests__/listing-form-steps.spec.tsx index 234ca5f..15538da 100644 --- a/apps/web/components/listings/__tests__/listing-form-steps.spec.tsx +++ b/apps/web/components/listings/__tests__/listing-form-steps.spec.tsx @@ -108,9 +108,11 @@ describe('StepLocation', () => { expect(screen.getByLabelText('Kinh độ')).toBeInTheDocument(); }); - it('renders map placeholder text', () => { + it('renders latitude + longitude fields without the picker when setValue is not passed', () => { render(); - expect(screen.getByText(/Bản đồ chọn vị trí sẽ được tích hợp/)).toBeInTheDocument(); + // Picker is opt-in: only mounts when setValue/watch are provided. + expect(screen.getByLabelText('Vĩ độ')).toBeInTheDocument(); + expect(screen.getByLabelText('Kinh độ')).toBeInTheDocument(); }); it('shows error for address', () => { diff --git a/apps/web/components/listings/listing-form-steps.tsx b/apps/web/components/listings/listing-form-steps.tsx index 608aacf..c9eaba4 100644 --- a/apps/web/components/listings/listing-form-steps.tsx +++ b/apps/web/components/listings/listing-form-steps.tsx @@ -1,6 +1,7 @@ 'use client'; -import type { UseFormRegister, FieldErrors } from 'react-hook-form'; +import dynamic from 'next/dynamic'; +import type { UseFormRegister, UseFormSetValue, UseFormWatch, FieldErrors } from 'react-hook-form'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select } from '@/components/ui/select'; @@ -14,11 +15,34 @@ import { type CreateListingFormData, } from '@/lib/validations/listings'; +// Mapbox picker is client-only + imports `mapbox-gl` which pulls WebGL +// utilities — dynamic-import with ssr:false to keep it out of the server +// bundle. +const LocationPicker = dynamic( + () => import('@/components/map/location-picker').then((m) => m.LocationPicker), + { + ssr: false, + loading: () => ( +
+ Đang tải bản đồ... +
+ ), + }, +); + interface StepProps { register: UseFormRegister; errors: FieldErrors; } +interface StepLocationProps extends StepProps { + /** Optional — when provided, StepLocation renders the Mapbox picker and + * writes latitude/longitude (+ auto-fills address/ward/district/city on + * geocoder pick) into the form state. */ + setValue?: UseFormSetValue; + watch?: UseFormWatch; +} + function FieldError({ message }: { message?: string }) { if (!message) return null; return

{message}

; @@ -81,11 +105,38 @@ export function StepBasicInfo({ register, errors }: StepProps) { // ─── Step 2: Location ──────────────────────────────────── -export function StepLocation({ register, errors }: StepProps) { +export function StepLocation({ register, errors, setValue, watch }: StepLocationProps) { + // Watch lat/lng so the picker stays in sync when the user edits the text + // inputs manually. + const latStr = watch?.('latitude') ?? ''; + const lngStr = watch?.('longitude') ?? ''; + const latNum = latStr ? Number(latStr) : null; + const lngNum = lngStr ? Number(lngStr) : null; + const latValid = latNum != null && Number.isFinite(latNum) && latNum >= -90 && latNum <= 90; + const lngValid = lngNum != null && Number.isFinite(lngNum) && lngNum >= -180 && lngNum <= 180; + return (

Vị trí

+ {setValue && ( + { + setValue('latitude', coords.lat.toFixed(6), { shouldValidate: true, shouldDirty: true }); + setValue('longitude', coords.lng.toFixed(6), { shouldValidate: true, shouldDirty: true }); + if (resolved) { + if (resolved.address) setValue('address', resolved.address, { shouldDirty: true }); + if (resolved.ward) setValue('ward', resolved.ward, { shouldDirty: true }); + if (resolved.district) setValue('district', resolved.district, { shouldDirty: true }); + if (resolved.city) setValue('city', resolved.city, { shouldDirty: true }); + } + }} + height="360px" + /> + )} +
@@ -134,10 +185,6 @@ export function StepLocation({ register, errors }: StepProps) {
- -
- Bản đồ chọn vị trí sẽ được tích hợp trong phiên bản tiếp theo -
); } diff --git a/apps/web/components/map/location-picker.tsx b/apps/web/components/map/location-picker.tsx new file mode 100644 index 0000000..3fff46d --- /dev/null +++ b/apps/web/components/map/location-picker.tsx @@ -0,0 +1,281 @@ +'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í +
+
+ ); +}