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:
@@ -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 →
|
||||
</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 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: 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user