Some checks failed
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Deploy to Staging (push) Has been cancelled
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 5s
CI / AI Services (Python) — Smoke (push) Failing after 5s
Deploy / Build Web Image (push) Failing after 4s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 43s
Deploy / Build API Image (push) Failing after 6s
Security Scanning / Trivy Scan — AI Services Image (push) Has started running
Security Scanning / Trivy Filesystem Scan (push) Has been cancelled
Security Scanning / Security Gate (push) Has been cancelled
CI / E2E Tests (push) Has been skipped
Deploy / Build AI Services Image (push) Failing after 4s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
E2E Tests / Playwright E2E (push) Failing after 8s
Security Scanning / Trivy Scan — API Image (push) Failing after 36s
Security Scanning / Trivy Scan — Web Image (push) Failing after 49s
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
- Hide the desktop horizontal FilterBar in list/split modes — the sidebar already renders an identical control set, so showing both duplicated every dropdown. Keep horizontal bar only when in map mode where there's no sidebar. - Replace `hsl(var(--…))` paint colors in ListingMap with literal hex constants. Mapbox-gl's color parser rejects CSS variable references and was throwing 'circle-color: Could not parse color from value hsl(var(--primary))' for cluster + marker layers, leaving the map blank. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
508 lines
18 KiB
TypeScript
508 lines
18 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 { 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<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;
|
|
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<GeoJSON.Point> {
|
|
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 (
|
|
<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 popupRef = React.useRef<mapboxgl.Popup | null>(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 (
|
|
<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>{`
|
|
.mapboxgl-popup-content {
|
|
border-radius: 8px !important;
|
|
padding: 12px !important;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|