feat(web): add listing detail page and Mapbox GL JS map integration

- Create public listing detail page at /listings/[id] with image gallery,
  property specs, contact card, and embedded map
- Rewrite ListingMap component to use Mapbox GL JS with interactive markers,
  price labels, and listing popups
- Add selectedListingId prop to search page map views for marker highlighting
- Install mapbox-gl dependency

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 05:12:48 +07:00
parent 51c6eed565
commit b6bb422d33
5 changed files with 729 additions and 136 deletions

View File

@@ -1,8 +1,9 @@
'use client';
import * as React from 'react';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import type { ListingDetail } from '@/lib/listings-api';
function formatPrice(priceVND: string): string {
@@ -15,6 +16,7 @@ function formatPrice(priceVND: string): string {
interface ListingMapProps {
listings: ListingDetail[];
onMarkerClick?: (listing: ListingDetail) => void;
selectedListingId?: string;
className?: string;
}
@@ -24,160 +26,203 @@ interface MapMarker {
lng: number;
}
export function ListingMap({ listings, onMarkerClick, className }: ListingMapProps) {
const [selectedMarker, setSelectedMarker] = React.useState<MapMarker | null>(null);
const mapRef = React.useRef<HTMLDivElement>(null);
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],
};
// Parse listings with valid coordinates
const markers = React.useMemo(() => {
return listings
.filter((l) => {
// ListingDetail doesn't expose lat/lng directly, but the property might have it
// For now we'll use a simple city-based mapping as fallback
return true;
})
.map((listing, index) => {
// Generate approximate coordinates based on city/district for demo
// In production, these would come from the API
const cityCoords: 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 base = cityCoords[listing.property.city] || [10.8231, 106.6297];
// Add small random offset per listing for visual spread
const seed = listing.id.charCodeAt(0) + index;
const lat = base[0] + ((seed % 100) - 50) * 0.001;
const lng = base[1] + ((seed % 73) - 36) * 0.001;
return { listing, lat, lng };
});
}, [listings]);
const DEFAULT_CENTER: [number, number] = [106.6297, 10.8231]; // HCMC [lng, lat]
const DEFAULT_ZOOM = 12;
const handleMarkerClick = (marker: MapMarker) => {
setSelectedMarker(marker);
onMarkerClick?.(marker.listing);
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,
};
}
// CSS-based map visualization (no Mapbox dependency required)
// Uses a relative coordinate system to position markers
const bounds = React.useMemo(() => {
if (markers.length === 0) return { minLat: 10, maxLat: 22, minLng: 102, maxLng: 110 };
const lats = markers.map((m) => m.lat);
const lngs = markers.map((m) => m.lng);
const padding = 0.01;
return {
minLat: Math.min(...lats) - padding,
maxLat: Math.max(...lats) + padding,
minLng: Math.min(...lngs) - padding,
maxLng: Math.max(...lngs) + padding,
export function ListingMap({ 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 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;
};
}, [markers]);
}, []);
// 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;
el.innerHTML = `<span class="${isSelected ? 'selected' : ''}">${formatPrice(marker.listing.priceVND)}</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 showPopup(map: mapboxgl.Map, marker: MapMarker) {
popupRef.current?.remove();
const { listing } = marker;
const imgHtml = listing.property.media.length > 0
? `<img src="${listing.property.media[0]!.url}" alt="${listing.property.title}" style="width:100%;height:96px;object-fit:cover;border-radius:6px;margin-bottom:8px;" />`
: '';
const popup = new mapboxgl.Popup({ offset: 25, maxWidth: '260px', closeButton: true })
.setLngLat([marker.lng, marker.lat])
.setHTML(`
<div style="font-family:system-ui,sans-serif;">
${imgHtml}
<p style="font-weight:700;color:hsl(142.1,76.2%,36.3%);font-size:14px;margin:0 0 4px;">
${formatPrice(listing.priceVND)} VND
</p>
<p style="font-size:13px;font-weight:500;margin:0 0 2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
${listing.property.title}
</p>
<p style="font-size:12px;color:#666;margin:0 0 8px;">
${listing.property.district}, ${listing.property.city}
</p>
<div style="display:flex;gap:4px;font-size:11px;margin-bottom:8px;">
<span style="background:#f1f5f9;padding:2px 6px;border-radius:4px;">${listing.property.areaM2} m\u00B2</span>
${listing.property.bedrooms != null ? `<span style="background:#f1f5f9;padding:2px 6px;border-radius:4px;">${listing.property.bedrooms} PN</span>` : ''}
${listing.property.bathrooms != null ? `<span style="background:#f1f5f9;padding:2px 6px;border-radius:4px;">${listing.property.bathrooms} WC</span>` : ''}
</div>
<a href="/listings/${listing.id}" style="display:block;text-align:center;font-size:12px;font-weight:500;color:hsl(142.1,76.2%,36.3%);text-decoration:none;">
Xem chi tiet &rarr;
</a>
</div>
`)
.addTo(map);
popupRef.current = popup;
}
const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
return (
<div
ref={mapRef}
className={`relative overflow-hidden rounded-lg border bg-gradient-to-b from-blue-50 to-green-50 ${className || 'h-[500px]'}`}
>
{/* Grid lines for visual reference */}
<div className="absolute inset-0 opacity-10">
<div className="h-full w-full"
style={{
backgroundImage: 'linear-gradient(rgba(0,0,0,0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(0,0,0,0.1) 1px, transparent 1px)',
backgroundSize: '50px 50px',
}}
/>
</div>
<div className={`relative overflow-hidden rounded-lg border ${className || 'h-[500px]'}`}>
<div ref={mapContainerRef} className="h-full w-full" />
{/* Markers */}
{markers.map((marker) => {
const x = ((marker.lng - bounds.minLng) / (bounds.maxLng - bounds.minLng)) * 100;
const y = ((bounds.maxLat - marker.lat) / (bounds.maxLat - bounds.minLat)) * 100;
const isSelected = selectedMarker?.listing.id === marker.listing.id;
return (
<button
key={marker.listing.id}
className={`absolute z-10 -translate-x-1/2 -translate-y-full cursor-pointer transition-all hover:z-20 hover:scale-110 ${
isSelected ? 'z-20 scale-110' : ''
}`}
style={{ left: `${Math.min(Math.max(x, 5), 95)}%`, top: `${Math.min(Math.max(y, 5), 90)}%` }}
onClick={() => handleMarkerClick(marker)}
>
<div
className={`rounded-full px-2 py-1 text-xs font-bold shadow-md ${
isSelected
? 'bg-primary text-primary-foreground ring-2 ring-primary/30'
: 'bg-white text-foreground hover:bg-primary hover:text-primary-foreground'
}`}
>
{formatPrice(marker.listing.priceVND)}
</div>
<div className="mx-auto h-2 w-0 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-current" />
</button>
);
})}
{/* Selected marker popup */}
{selectedMarker && (
<div
className="absolute z-30 w-64 -translate-x-1/2 rounded-lg border bg-white p-3 shadow-lg"
style={{
left: `${Math.min(Math.max(((selectedMarker.lng - bounds.minLng) / (bounds.maxLng - bounds.minLng)) * 100, 15), 85)}%`,
top: `${Math.min(Math.max(((bounds.maxLat - selectedMarker.lat) / (bounds.maxLat - bounds.minLat)) * 100 - 15, 2), 60)}%`,
}}
>
<button
className="absolute right-1 top-1 rounded p-1 text-muted-foreground hover:bg-muted"
onClick={(e) => { e.stopPropagation(); setSelectedMarker(null); }}
>
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
{/* 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>
</button>
{selectedMarker.listing.property.media.length > 0 && (
<img
src={selectedMarker.listing.property.media[0]?.url}
alt={selectedMarker.listing.property.title}
className="mb-2 h-24 w-full rounded object-cover"
/>
)}
<p className="text-sm font-bold text-primary">
{formatPrice(selectedMarker.listing.priceVND)} VNĐ
</p>
<p className="line-clamp-1 text-sm font-medium">{selectedMarker.listing.property.title}</p>
<p className="line-clamp-1 text-xs text-muted-foreground">
{selectedMarker.listing.property.district}, {selectedMarker.listing.property.city}
</p>
<div className="mt-2 flex gap-1">
<Badge variant="secondary" className="text-xs">{selectedMarker.listing.property.areaM2} m²</Badge>
{selectedMarker.listing.property.bedrooms != null && (
<Badge variant="secondary" className="text-xs">{selectedMarker.listing.property.bedrooms} PN</Badge>
)}
<p className="text-sm text-muted-foreground">
Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN đ hiển th bản đ
</p>
</div>
<a
href={`/listings/${selectedMarker.listing.id}`}
className="mt-2 block text-center text-xs font-medium text-primary hover:underline"
>
Xem chi tiết
</a>
</div>
)}
{/* Map controls */}
<div className="absolute bottom-3 left-3 flex flex-col gap-1">
<div className="rounded bg-white/90 px-2 py-1 text-xs text-muted-foreground shadow">
{markers.length} bất đng sản trên bản đ
</div>
{/* Listing count overlay */}
<div className="absolute bottom-3 left-3 rounded bg-white/90 px-2 py-1 text-xs text-muted-foreground shadow">
{markers.length} bất đng sản trên bản đ
</div>
{/* Empty state */}
{markers.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center">
{markers.length === 0 && hasToken && (
<div className="absolute inset-0 flex items-center justify-center bg-white/60">
<p className="text-muted-foreground">Không bất đng sản đ hiển thị trên bản đ</p>
</div>
)}
<style jsx global>{`
.mapbox-price-marker span {
display: block;
background: white;
border-radius: 9999px;
padding: 4px 8px;
font-size: 12px;
font-weight: 700;
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
white-space: nowrap;
transition: all 0.15s;
}
.mapbox-price-marker:hover span,
.mapbox-price-marker span.selected {
background: hsl(142.1, 76.2%, 36.3%);
color: white;
transform: scale(1.1);
}
.mapboxgl-popup-content {
border-radius: 8px !important;
padding: 12px !important;
box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important;
}
`}</style>
</div>
);
}