- Map container: role="region" + aria-label="Bản đồ bất động sản" - Price marker buttons: aria-label with price/title/address, aria-pressed for selection state - Popup container: role="dialog" + aria-label with property title - NavigationControl buttons: Vietnamese aria-labels patched on map load - Listing-count overlay: bg-card/90 text-card-foreground + aria-live (was bg-white/90) - Empty-state overlay: role="status" + bg-card/60 (was bg-white/60), dark-mode safe Co-Authored-By: Paperclip <noreply@paperclip.ing>
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
'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 type { ListingDetail } from '@/lib/listings-api';
|
||
import { useMapboxStyle } from '@/lib/mapbox-style';
|
||
|
||
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<string, [number, number]> = {
|
||
'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 } {
|
||
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,
|
||
};
|
||
}
|
||
|
||
export function ListingMap(props: ListingMapProps) {
|
||
return (
|
||
<ComponentErrorBoundary label="bản đồ">
|
||
<ListingMapInner {...props} />
|
||
</ComponentErrorBoundary>
|
||
);
|
||
}
|
||
|
||
function ListingMapInner({ listings, onMarkerClick, selectedListingId, className }: ListingMapProps) {
|
||
const mapContainerRef = React.useRef<HTMLDivElement>(null);
|
||
const mapRef = React.useRef<mapboxgl.Map | null>(null);
|
||
const markersRef = React.useRef<mapboxgl.Marker[]>([]);
|
||
const popupRef = React.useRef<mapboxgl.Popup | null>(null);
|
||
const mapStyle = useMapboxStyle();
|
||
|
||
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: 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');
|
||
|
||
// Patch ARIA labels onto Mapbox's auto-generated navigation buttons (Vietnamese)
|
||
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');
|
||
});
|
||
|
||
mapRef.current = map;
|
||
|
||
return () => {
|
||
map.remove();
|
||
mapRef.current = null;
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// Sync style changes with theme
|
||
React.useEffect(() => {
|
||
const map = mapRef.current;
|
||
if (!map) return;
|
||
map.setStyle(mapStyle);
|
||
}, [mapStyle]);
|
||
|
||
// 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';
|
||
el.setAttribute('aria-pressed', 'true');
|
||
} else {
|
||
el.setAttribute('aria-pressed', 'false');
|
||
}
|
||
span.textContent = formatPrice(marker.listing.priceVND);
|
||
el.appendChild(span);
|
||
el.style.cssText = 'border:none;cursor:pointer;background:none;padding:0;';
|
||
const { property } = marker.listing;
|
||
const address = [property.district, property.city].filter(Boolean).join(', ');
|
||
el.setAttribute(
|
||
'aria-label',
|
||
`${formatPrice(marker.listing.priceVND)} VND – ${property.title}${address ? `, ${address}` : ''}`,
|
||
);
|
||
|
||
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.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;
|
||
}
|
||
|
||
function showPopup(map: mapboxgl.Map, marker: MapMarker) {
|
||
popupRef.current?.remove();
|
||
|
||
const popup = new mapboxgl.Popup({ offset: 25, maxWidth: 'min(260px, 85vw)', 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 (
|
||
<div
|
||
role="region"
|
||
aria-label="Bản đồ bất động sản"
|
||
className={`relative overflow-hidden rounded-lg border ${className || 'h-[300px] md:h-[500px]'}`}
|
||
>
|
||
<div ref={mapContainerRef} className="h-full w-full" />
|
||
|
||
{/* Fallback when no Mapbox token */}
|
||
{!hasToken && (
|
||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-blue-50 to-green-50">
|
||
<div className="text-center">
|
||
<svg className="mx-auto mb-2 h-10 w-10 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||
</svg>
|
||
<p className="text-sm text-muted-foreground">
|
||
Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để hiển thị bản đồ
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Listing count overlay */}
|
||
<div
|
||
aria-live="polite"
|
||
aria-atomic="true"
|
||
className="absolute bottom-3 left-3 rounded bg-card/90 px-2 py-1 text-xs text-card-foreground shadow"
|
||
>
|
||
{markers.length} bất động sản trên bản đồ
|
||
</div>
|
||
|
||
{/* Empty state */}
|
||
{markers.length === 0 && hasToken && (
|
||
<div
|
||
role="status"
|
||
className="absolute inset-0 flex items-center justify-center bg-card/60"
|
||
>
|
||
<p className="text-muted-foreground">Không có bất động sản để hiển thị trên bản đồ</p>
|
||
</div>
|
||
)}
|
||
|
||
<style jsx global>{`
|
||
.mapbox-price-marker span {
|
||
display: block;
|
||
background: hsl(var(--card));
|
||
color: hsl(var(--card-foreground));
|
||
border-radius: 9999px;
|
||
padding: 4px 8px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
|
||
white-space: nowrap;
|
||
transition: all 0.15s;
|
||
}
|
||
.mapbox-price-marker:hover span,
|
||
.mapbox-price-marker span.selected {
|
||
background: hsl(var(--primary));
|
||
color: hsl(var(--primary-foreground));
|
||
transform: scale(1.1);
|
||
}
|
||
.mapboxgl-popup-content {
|
||
border-radius: 8px !important;
|
||
padding: 12px !important;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.3) !important;
|
||
}
|
||
`}</style>
|
||
</div>
|
||
);
|
||
}
|