Files
goodgo-platform/apps/web/components/map/listing-map.tsx
Ho Ngoc Hai 925863e471
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
fix(web): /search — fix duplicated filter bar + invisible map markers
- 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>
2026-04-29 17:54:28 +07:00

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}`;
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 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>
);
}