'use client'; /* eslint-disable import-x/no-named-as-default-member */ import mapboxgl from 'mapbox-gl'; import * as React from 'react'; import 'mapbox-gl/dist/mapbox-gl.css'; import { ComponentErrorBoundary } from '@/components/error-boundary'; import { formatPrice } from '@/lib/currency'; import type { ListingDetail } from '@/lib/listings-api'; import { useMapboxStyle } from '@/lib/mapbox-style'; interface ListingMapProps { listings: ListingDetail[]; onMarkerClick?: (listing: ListingDetail) => void; selectedListingId?: string; className?: string; } interface MapMarker { listing: ListingDetail; lat: number; lng: number; } const CITY_COORDS: Record = { 'Hồ Chí Minh': [10.8231, 106.6297], 'Hà Nội': [21.0285, 105.8542], 'Đà Nẵng': [16.0544, 108.2022], 'Nha Trang': [12.2388, 109.1967], 'Cần Thơ': [10.0452, 105.7469], }; const DEFAULT_CENTER: [number, number] = [106.6297, 10.8231]; // HCMC [lng, lat] const DEFAULT_ZOOM = 12; const CLUSTER_SOURCE_ID = 'listings-cluster-source'; const CLUSTER_LAYER_ID = 'listings-cluster-circles'; const CLUSTER_COUNT_LAYER_ID = 'listings-cluster-count'; const UNCLUSTERED_LAYER_ID = 'listings-unclustered'; const UNCLUSTERED_SELECTED_LAYER_ID = 'listings-unclustered-selected'; function getMarkerCoords(listing: ListingDetail, index: number): { lat: number; lng: number } { if (listing.property.latitude != null && listing.property.longitude != null) { return { lat: listing.property.latitude, lng: listing.property.longitude }; } const base = CITY_COORDS[listing.property.city] || [10.8231, 106.6297]; const seed = listing.id.charCodeAt(0) + index; return { lat: base[0] + ((seed % 100) - 50) * 0.001, lng: base[1] + ((seed % 73) - 36) * 0.001, }; } function buildGeoJSON( markers: MapMarker[], selectedListingId?: string, ): GeoJSON.FeatureCollection { return { type: 'FeatureCollection', features: markers.map(({ listing, lat, lng }) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [lng, lat] }, properties: { id: listing.id, price: formatPrice(listing.priceVND), title: listing.property.title, district: listing.property.district ?? '', city: listing.property.city ?? '', areaM2: listing.property.areaM2, bedrooms: listing.property.bedrooms ?? null, bathrooms: listing.property.bathrooms ?? null, imageUrl: listing.property.media?.[0]?.url ?? null, selected: listing.id === selectedListingId ? 1 : 0, }, })), }; } function buildPopupContent(listing: ListingDetail): HTMLDivElement { const container = document.createElement('div'); container.setAttribute('role', 'dialog'); container.setAttribute('aria-label', `Chi tiết: ${listing.property.title}`); container.style.cssText = 'font-family:system-ui,sans-serif;background:hsl(var(--card));color:hsl(var(--card-foreground));padding:8px;border-radius:6px;'; if ((listing.property.media?.length ?? 0) > 0) { const img = document.createElement('img'); img.src = listing.property.media![0]!.url; img.alt = listing.property.title; img.style.cssText = 'width:100%;height:96px;object-fit:cover;border-radius:6px;margin-bottom:8px;'; container.appendChild(img); } const price = document.createElement('p'); price.style.cssText = 'font-weight:700;color:hsl(var(--primary));font-size:14px;margin:0 0 4px;'; price.textContent = `${formatPrice(listing.priceVND)} VND`; container.appendChild(price); const title = document.createElement('p'); title.style.cssText = 'font-size:13px;font-weight:500;margin:0 0 2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; title.textContent = listing.property.title; container.appendChild(title); const location = document.createElement('p'); location.style.cssText = 'font-size:12px;color:hsl(var(--muted-foreground));margin:0 0 8px;'; location.textContent = `${listing.property.district}, ${listing.property.city}`; container.appendChild(location); const details = document.createElement('div'); details.style.cssText = 'display:flex;gap:4px;font-size:11px;margin-bottom:8px;'; const tagStyle = 'background:hsl(var(--secondary));color:hsl(var(--secondary-foreground));padding:2px 6px;border-radius:4px;'; const areaTag = document.createElement('span'); areaTag.style.cssText = tagStyle; areaTag.textContent = `${listing.property.areaM2} m²`; details.appendChild(areaTag); if (listing.property.bedrooms != null) { const bedTag = document.createElement('span'); bedTag.style.cssText = tagStyle; bedTag.textContent = `${listing.property.bedrooms} PN`; details.appendChild(bedTag); } if (listing.property.bathrooms != null) { const bathTag = document.createElement('span'); bathTag.style.cssText = tagStyle; bathTag.textContent = `${listing.property.bathrooms} WC`; details.appendChild(bathTag); } container.appendChild(details); const link = document.createElement('a'); link.href = `/listings/${listing.id}`; link.style.cssText = 'display:block;text-align:center;font-size:12px;font-weight:500;color:hsl(var(--primary));text-decoration:none;'; link.textContent = 'Xem chi tiết →'; container.appendChild(link); return container; } export function ListingMap(props: ListingMapProps) { return ( ); } function ListingMapInner({ listings, onMarkerClick, selectedListingId, className, }: ListingMapProps) { const mapContainerRef = React.useRef(null); const mapRef = React.useRef(null); const popupRef = React.useRef(null); const layersReadyRef = React.useRef(false); const mapStyle = useMapboxStyle(); // Stable ref so click handlers never form a closure on stale callbacks const onMarkerClickRef = React.useRef(onMarkerClick); React.useEffect(() => { onMarkerClickRef.current = onMarkerClick; }); const markers: MapMarker[] = React.useMemo( () => listings.map((listing, index) => ({ listing, ...getMarkerCoords(listing, index) })), [listings], ); // Build GeoJSON without depending on selectedListingId so the source update // path (for selection highlights) stays separate from the full data reload. const geojson = React.useMemo(() => buildGeoJSON(markers), [markers]); // Helper: add or replace the cluster + unclustered layers on a loaded map const addLayers = React.useCallback((map: mapboxgl.Map) => { // Remove if they already exist (e.g. after a style reload) for (const id of [ UNCLUSTERED_SELECTED_LAYER_ID, UNCLUSTERED_LAYER_ID, CLUSTER_COUNT_LAYER_ID, CLUSTER_LAYER_ID, ]) { if (map.getLayer(id)) map.removeLayer(id); } if (map.getSource(CLUSTER_SOURCE_ID)) map.removeSource(CLUSTER_SOURCE_ID); map.addSource(CLUSTER_SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, cluster: true, clusterMaxZoom: 14, clusterRadius: 50, }); // Cluster circles. Mapbox-gl's color parser rejects `hsl(var(--…))` — // it only accepts literal CSS colors. We use hex constants tuned to // match the design-system primary/accent palette in dark mode. map.addLayer({ id: CLUSTER_LAYER_ID, type: 'circle', source: CLUSTER_SOURCE_ID, filter: ['has', 'point_count'], paint: { 'circle-color': [ 'step', ['get', 'point_count'], '#22c55e', // primary (emerald-500) 10, '#f1a928', 30, '#e5633a', ], 'circle-radius': ['step', ['get', 'point_count'], 20, 10, 28, 30, 36], 'circle-opacity': 0.85, }, }); // Cluster counts map.addLayer({ id: CLUSTER_COUNT_LAYER_ID, type: 'symbol', source: CLUSTER_SOURCE_ID, filter: ['has', 'point_count'], layout: { 'text-field': ['get', 'point_count_abbreviated'], 'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'], 'text-size': 13, }, paint: { 'text-color': '#ffffff' }, }); // Unclustered — normal markers map.addLayer({ id: UNCLUSTERED_LAYER_ID, type: 'symbol', source: CLUSTER_SOURCE_ID, filter: ['all', ['!', ['has', 'point_count']], ['==', ['get', 'selected'], 0]], layout: { 'text-field': ['get', 'price'], 'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'], 'text-size': 12, 'text-anchor': 'center', 'icon-allow-overlap': true, 'text-allow-overlap': true, }, paint: { 'text-color': '#f5f5f4', // card-foreground (stone-100) 'text-halo-color': '#1c1917', // card (stone-900) 'text-halo-width': 8, }, }); // Unclustered — selected marker (on top, different style) map.addLayer({ id: UNCLUSTERED_SELECTED_LAYER_ID, type: 'symbol', source: CLUSTER_SOURCE_ID, filter: ['all', ['!', ['has', 'point_count']], ['==', ['get', 'selected'], 1]], layout: { 'text-field': ['get', 'price'], 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-size': 13, 'text-anchor': 'center', 'icon-allow-overlap': true, 'text-allow-overlap': true, }, paint: { 'text-color': '#ffffff', // primary-foreground (high-contrast on emerald) 'text-halo-color': '#22c55e', // primary (emerald-500) 'text-halo-width': 10, }, }); layersReadyRef.current = true; }, []); // Initialize map once React.useEffect(() => { if (!mapContainerRef.current) return; const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; if (!token) { console.warn('NEXT_PUBLIC_MAPBOX_TOKEN is not set. Map will not render.'); return; } mapboxgl.accessToken = token; const map = new mapboxgl.Map({ container: mapContainerRef.current, style: mapStyle, center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, attributionControl: false, }); map.addControl(new mapboxgl.NavigationControl(), 'top-right'); map.addControl(new mapboxgl.AttributionControl({ compact: true }), 'bottom-right'); map.once('load', () => { const container = mapContainerRef.current; if (!container) return; const zoomIn = container.querySelector('.mapboxgl-ctrl-zoom-in') as HTMLButtonElement | null; const zoomOut = container.querySelector( '.mapboxgl-ctrl-zoom-out', ) as HTMLButtonElement | null; const compass = container.querySelector( '.mapboxgl-ctrl-compass', ) as HTMLButtonElement | null; if (zoomIn) zoomIn.setAttribute('aria-label', 'Phóng to'); if (zoomOut) zoomOut.setAttribute('aria-label', 'Thu nhỏ'); if (compass) compass.setAttribute('aria-label', 'Đặt lại hướng bắc'); addLayers(map); }); // Re-add layers after style hot-swap map.on('style.load', () => { if (mapRef.current) addLayers(mapRef.current); }); // --- Click: cluster → zoom in --- map.on('click', CLUSTER_LAYER_ID, (e) => { const features = map.queryRenderedFeatures(e.point, { layers: [CLUSTER_LAYER_ID] }); if (!features.length) return; const feature = features[0]!; const source = map.getSource(CLUSTER_SOURCE_ID) as mapboxgl.GeoJSONSource; source.getClusterExpansionZoom( (feature.properties as { cluster_id: number }).cluster_id, (err: Error | null | undefined, zoom: number | null | undefined) => { if (err || zoom == null) return; const geom = feature.geometry as GeoJSON.Point; map.easeTo({ center: geom.coordinates as [number, number], zoom }); }, ); }); // --- Click: unclustered marker → show popup --- const handleUnclusteredClick = ( e: mapboxgl.MapLayerMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }, ) => { if (!e.features?.length) return; const props = e.features[0]!.properties as { id: string; price: string; title: string; district: string; city: string; areaM2: number; bedrooms: number | null; bathrooms: number | null; imageUrl: string | null; }; const geom = e.features[0]!.geometry as GeoJSON.Point; const [lng, lat] = geom.coordinates as [number, number]; // Find full listing for popup const listing = listings.find((l) => l.id === props.id); if (!listing) return; onMarkerClickRef.current?.(listing); popupRef.current?.remove(); const popup = new mapboxgl.Popup({ offset: 25, maxWidth: 'min(260px, 85vw)', closeButton: true, }) .setLngLat([lng, lat]) .setDOMContent(buildPopupContent(listing)) .addTo(map); popupRef.current = popup; }; map.on('click', UNCLUSTERED_LAYER_ID, handleUnclusteredClick); map.on('click', UNCLUSTERED_SELECTED_LAYER_ID, handleUnclusteredClick); // Cursor changes for (const layer of [CLUSTER_LAYER_ID, UNCLUSTERED_LAYER_ID, UNCLUSTERED_SELECTED_LAYER_ID]) { map.on('mouseenter', layer, () => { map.getCanvas().style.cursor = 'pointer'; }); map.on('mouseleave', layer, () => { map.getCanvas().style.cursor = ''; }); } mapRef.current = map; return () => { layersReadyRef.current = false; map.remove(); mapRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Sync style changes with theme (without reinitializing map) React.useEffect(() => { const map = mapRef.current; if (!map) return; layersReadyRef.current = false; map.setStyle(mapStyle); // layers are re-added via the 'style.load' listener above }, [mapStyle]); // Push GeoJSON data update when listings array identity changes. // This fires on full filter changes but NOT on selectedListingId-only changes, // which avoids full marker teardown for selection highlighting. React.useEffect(() => { const map = mapRef.current; if (!map || !layersReadyRef.current) return; const source = map.getSource(CLUSTER_SOURCE_ID) as mapboxgl.GeoJSONSource | undefined; if (!source) return; source.setData(geojson); // Only fit bounds when listings actually changed (not on mount w/ 0 markers) if (markers.length > 1) { const bounds = new mapboxgl.LngLatBounds(); markers.forEach(({ lat, lng }) => bounds.extend([lng, lat])); map.fitBounds(bounds, { padding: 60, maxZoom: 15, duration: 400 }); } else if (markers.length === 1) { map.flyTo({ center: [markers[0]!.lng, markers[0]!.lat], zoom: 14, duration: 400 }); } }, [geojson, markers]); // ── Selection highlight: update only the `selected` property on each feature. // No source reload needed — setData is cheap for 1-property diff but we use // a filter-based approach instead: simply regenerate GeoJSON with updated // `selected` flags. Because `geojson` memo doesn't include selectedListingId, // we build a tiny delta update here. React.useEffect(() => { const map = mapRef.current; if (!map || !layersReadyRef.current) return; const source = map.getSource(CLUSTER_SOURCE_ID) as mapboxgl.GeoJSONSource | undefined; if (!source) return; // Rebuild GeoJSON with updated selection flags (no viewport/bounds change) source.setData(buildGeoJSON(markers, selectedListingId)); }, [selectedListingId, markers]); const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; return (
{/* Fallback when no Mapbox token */} {!hasToken && (

Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để hiển thị bản đồ

)} {/* Listing count overlay */}
{markers.length} bất động sản trên bản đồ
{/* Empty state */} {markers.length === 0 && hasToken && (

Không có bất động sản để hiển thị trên bản đồ

)}
); }