Files
goodgo-platform/apps/web/components/map/listing-map.tsx
Ho Ngoc Hai 0fc6516880
Some checks failed
Security Scanning / Trivy Filesystem Scan (push) Failing after 31s
Security Scanning / Security Gate (push) Failing after 2s
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 13s
Deploy / Build API Image (push) Failing after 36s
Deploy / Build Web Image (push) Failing after 12s
Deploy / Build AI Services Image (push) Failing after 12s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m5s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m24s
E2E Tests / Playwright E2E (push) Failing after 20s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Deploy / Deploy to Production (push) Has been cancelled
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
Security Scanning / Trivy Scan — AI Services Image (push) Has been cancelled
Security Scanning / Trivy Scan — Web Image (push) Has been cancelled
feat(maps): dark/light Mapbox theme + fix empty Image src & missing keys
Mapbox theming
--------------
- New hook `lib/mapbox-style.ts` returning streets-v12 (light) or
  dark-v11 (dark) from the app's useTheme().
- Six map components now initialise with the themed style and
  `map.setStyle(...)` on theme change: project-map, park-map,
  listing-map, district-heatmap (plus re-adding its heatmap source
  after style.load), neighborhood-poi-map, valuation/comparables-map.
- Marker / popup DOM styles swapped from hard-coded white/#666/#green
  to shadcn CSS tokens (--card, --card-foreground, --muted-foreground,
  --primary, --border). Global Mapbox popup + control + attribution
  skins added in app/globals.css.
- POI filter pills on neighborhood-poi-map were hard-coded `bg-white`
  which rendered same-colour text on white in dark mode — switched to
  `bg-card`/`bg-card/60` for proper contrast.
- Extend the MockMap in comparables-map.spec.tsx with setStyle/on
  so the new theme-sync effect doesn't blow up in tests.

Detail client normaliser (du-an-server)
---------------------------------------
- Project media from the backend is a `string[]` (raw URLs) or richer
  `{url,...}` objects. Handle both shapes and drop entries without
  a URL so we never feed "" to <Image src>.
- Amenities are `string[]` in the DB but the frontend type expects
  `{id,name,icon,category}`; normalise strings into objects so the
  AmenitiesTab has stable keys and a displayable name.

Resolves three classes of runtime warnings on /du-an/<slug>:
"Image is missing required 'src' property", "ReactDOM.preload ...
empty href", and "Each child in a list should have a unique 'key'
prop" (AmenitiesTab).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:12:28 +07:00

279 lines
10 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 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({ 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');
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';
span.textContent = formatPrice(marker.listing.priceVND);
el.appendChild(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 buildPopupContent(listing: ListingDetail): HTMLDivElement {
const container = document.createElement('div');
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\u00B2`;
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\u1EBFt \u2192';
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 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 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 && 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: 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>
);
}