'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 type { ListingDetail } from '@/lib/listings-api'; function formatPrice(priceVND: string): string { const num = Number(priceVND); if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ`; if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} tr`; return num.toLocaleString('vi-VN'); } 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; function getMarkerCoords(listing: ListingDetail, index: number): { lat: number; lng: number } { 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, }; } export function ListingMap({ listings, onMarkerClick, selectedListingId, className }: ListingMapProps) { const mapContainerRef = React.useRef(null); const mapRef = React.useRef(null); const markersRef = React.useRef([]); const popupRef = React.useRef(null); const markers: MapMarker[] = React.useMemo( () => listings.map((listing, index) => ({ listing, ...getMarkerCoords(listing, index) })), [listings], ); // Initialize map 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: 'mapbox://styles/mapbox/streets-v12', center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, attributionControl: false, }); map.addControl(new mapboxgl.NavigationControl(), 'top-right'); map.addControl(new mapboxgl.AttributionControl({ compact: true }), 'bottom-right'); mapRef.current = map; return () => { map.remove(); mapRef.current = null; }; }, []); // Update markers when listings change React.useEffect(() => { const map = mapRef.current; if (!map) return; // Clear existing markers markersRef.current.forEach((m) => m.remove()); markersRef.current = []; if (markers.length === 0) return; const bounds = new mapboxgl.LngLatBounds(); markers.forEach((marker) => { const el = document.createElement('button'); el.className = 'mapbox-price-marker'; const isSelected = selectedListingId === marker.listing.id; const span = document.createElement('span'); if (isSelected) span.className = 'selected'; span.textContent = formatPrice(marker.listing.priceVND); el.appendChild(span); el.style.cssText = 'border:none;cursor:pointer;background:none;padding:0;'; el.addEventListener('click', (e) => { e.stopPropagation(); onMarkerClick?.(marker.listing); showPopup(map, marker); }); const mbMarker = new mapboxgl.Marker({ element: el, anchor: 'bottom' }) .setLngLat([marker.lng, marker.lat]) .addTo(map); markersRef.current.push(mbMarker); bounds.extend([marker.lng, marker.lat]); }); // Fit bounds with padding if (markers.length > 1) { map.fitBounds(bounds, { padding: 60, maxZoom: 15 }); } else { map.flyTo({ center: [markers[0]!.lng, markers[0]!.lat], zoom: 14 }); } }, [markers, selectedListingId, onMarkerClick]); function buildPopupContent(listing: ListingDetail): HTMLDivElement { const container = document.createElement('div'); container.style.fontFamily = 'system-ui,sans-serif'; if (listing.property.media.length > 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(142.1,76.2%,36.3%);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:#666;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:#f1f5f9;padding:2px 6px;border-radius:4px;'; const areaTag = document.createElement('span'); areaTag.style.cssText = tagStyle; areaTag.textContent = `${listing.property.areaM2} m\u00B2`; 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(142.1,76.2%,36.3%);text-decoration:none;'; link.textContent = 'Xem chi ti\u1EBFt \u2192'; container.appendChild(link); return container; } function showPopup(map: mapboxgl.Map, marker: MapMarker) { popupRef.current?.remove(); const popup = new mapboxgl.Popup({ offset: 25, maxWidth: '260px', closeButton: true }) .setLngLat([marker.lng, marker.lat]) .setDOMContent(buildPopupContent(marker.listing)) .addTo(map); popupRef.current = popup; } 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 đồ

)}
); }