From 0fc651688080ecd20634ec2fef6ae5b5404a7b1d Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sun, 19 Apr 2026 14:12:28 +0700 Subject: [PATCH] feat(maps): dark/light Mapbox theme + fix empty Image src & missing keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 . - 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/: "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) --- apps/web/app/globals.css | 25 +++++++ .../components/charts/district-heatmap.tsx | 25 +++++-- apps/web/components/du-an/project-map.tsx | 32 ++++++--- .../components/khu-cong-nghiep/park-map.tsx | 28 +++++--- apps/web/components/map/listing-map.tsx | 33 ++++++--- .../neighborhood/neighborhood-poi-map.tsx | 26 ++++--- .../__tests__/comparables-map.spec.tsx | 2 + .../components/valuation/comparables-map.tsx | 11 ++- apps/web/lib/du-an-server.ts | 68 +++++++++++++++---- apps/web/lib/mapbox-style.ts | 16 +++++ 10 files changed, 209 insertions(+), 57 deletions(-) create mode 100644 apps/web/lib/mapbox-style.ts diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index b12cd0f..c79475b 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -52,3 +52,28 @@ @apply bg-background text-foreground; } } + +/* Mapbox popup theming */ +.mapboxgl-popup-content { + background: hsl(var(--card)); + color: hsl(var(--card-foreground)); + border: 1px solid hsl(var(--border)); +} +.mapboxgl-popup-tip { + border-top-color: hsl(var(--card)) !important; + border-bottom-color: hsl(var(--card)) !important; +} +.mapboxgl-ctrl button { + background-color: hsl(var(--card)); + color: hsl(var(--card-foreground)); +} +.mapboxgl-ctrl button:hover { + background-color: hsl(var(--accent)); +} +.mapboxgl-ctrl-attrib { + background: hsl(var(--card) / 0.8); + color: hsl(var(--muted-foreground)); +} +.mapboxgl-ctrl-attrib a { + color: hsl(var(--muted-foreground)); +} diff --git a/apps/web/components/charts/district-heatmap.tsx b/apps/web/components/charts/district-heatmap.tsx index 69b1435..1104ee5 100644 --- a/apps/web/components/charts/district-heatmap.tsx +++ b/apps/web/components/charts/district-heatmap.tsx @@ -4,6 +4,7 @@ import mapboxgl from 'mapbox-gl'; import * as React from 'react'; import 'mapbox-gl/dist/mapbox-gl.css'; +import { MAPBOX_STYLE_DARK, useMapboxStyle } from '@/lib/mapbox-style'; export interface HeatmapPoint { district: string; @@ -97,6 +98,11 @@ export function DistrictHeatmap({ data, city, className, onDistrictClick }: Dist const mapContainerRef = React.useRef(null); const mapRef = React.useRef(null); const markersRef = React.useRef([]); + const themeStyle = useMapboxStyle(); + // Heatmap uses the lighter "light-v11" backdrop for better marker contrast in + // light mode; in dark mode, fall back to the shared dark style. + const mapStyle = + themeStyle === MAPBOX_STYLE_DARK ? MAPBOX_STYLE_DARK : 'mapbox://styles/mapbox/light-v11'; const maxPrice = React.useMemo(() => Math.max(...data.map((d) => d.avgPriceM2), 1), [data]); @@ -111,7 +117,7 @@ export function DistrictHeatmap({ data, city, className, onDistrictClick }: Dist const center = CITY_CENTER[city] ?? [106.66, 10.79]; const map = new mapboxgl.Map({ container: mapContainerRef.current, - style: 'mapbox://styles/mapbox/light-v11', + style: mapStyle, center: center as [number, number], zoom: 11, attributionControl: false, @@ -124,8 +130,19 @@ export function DistrictHeatmap({ data, city, className, onDistrictClick }: Dist map.remove(); mapRef.current = null; }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [city]); + // Sync style changes with theme. The heatmap "layer" is implemented as DOM + // markers (re-added by the effect below when data changes), so we also need + // to re-add markers after the style finishes loading in case the style swap + // raced with a data update. + React.useEffect(() => { + const map = mapRef.current; + if (!map) return; + map.setStyle(mapStyle); + }, [mapStyle]); + // Update markers React.useEffect(() => { const map = mapRef.current; @@ -172,10 +189,10 @@ export function DistrictHeatmap({ data, city, className, onDistrictClick }: Dist const popup = new mapboxgl.Popup({ offset: 15, closeButton: false }) .setHTML(` -
+
${point.district}
-
${priceLabel}
-
${point.totalListings} tin đăng
+
${priceLabel}
+
${point.totalListings} tin đăng
`); diff --git a/apps/web/components/du-an/project-map.tsx b/apps/web/components/du-an/project-map.tsx index e53afeb..2d415e1 100644 --- a/apps/web/components/du-an/project-map.tsx +++ b/apps/web/components/du-an/project-map.tsx @@ -9,6 +9,7 @@ import { PROJECT_STATUS_LABELS, type ProjectSummary, } from '@/lib/du-an-api'; +import { useMapboxStyle } from '@/lib/mapbox-style'; interface ProjectMapProps { projects: ProjectSummary[]; @@ -22,6 +23,7 @@ export function ProjectMap({ projects, className }: ProjectMapProps) { const mapContainerRef = React.useRef(null); const mapRef = React.useRef(null); const markersRef = React.useRef([]); + const mapStyle = useMapboxStyle(); const geoProjects = React.useMemo( () => projects.filter((p) => p.latitude != null && p.longitude != null), @@ -38,7 +40,7 @@ export function ProjectMap({ projects, className }: ProjectMapProps) { const map = new mapboxgl.Map({ container: mapContainerRef.current, - style: 'mapbox://styles/mapbox/streets-v12', + style: mapStyle, center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, attributionControl: false, @@ -53,8 +55,19 @@ export function ProjectMap({ projects, className }: ProjectMapProps) { map.remove(); mapRef.current = null; }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + React.useEffect(() => { + const map = mapRef.current; + if (!map) return; + // setStyle wipes user-added sources/layers — but these components re-add + // markers/sources in another effect that fires after geo data changes. + // We only need to preserve markers. Since markers are DOM-attached, they + // survive the style swap automatically. + map.setStyle(mapStyle); + }, [mapStyle]); + React.useEffect(() => { const map = mapRef.current; if (!map) return; @@ -70,15 +83,16 @@ export function ProjectMap({ projects, className }: ProjectMapProps) { const el = document.createElement('div'); el.className = 'project-map-marker'; el.style.cssText = ` - background: white; + background: hsl(var(--card)); + color: hsl(var(--card-foreground)); border-radius: 8px; padding: 4px 8px; font-size: 11px; font-weight: 600; - box-shadow: 0 2px 6px rgba(0,0,0,0.15); + box-shadow: 0 2px 6px rgba(0,0,0,0.3); white-space: nowrap; cursor: pointer; - border-left: 3px solid hsl(142.1, 76.2%, 36.3%); + border-left: 3px solid hsl(var(--primary)); transition: transform 0.15s; max-width: 160px; overflow: hidden; @@ -97,14 +111,14 @@ export function ProjectMap({ projects, className }: ProjectMapProps) { const popup = new mapboxgl.Popup({ offset: 15, maxWidth: '240px', closeButton: false }) .setHTML( - `
+ `

${project.name}

-

${project.district}, ${project.city}

+

${project.district}, ${project.city}

- ${statusLabel} - ${priceText} + ${statusLabel} + ${priceText}

- Xem chi tiết → + Xem chi tiết →
`, ); diff --git a/apps/web/components/khu-cong-nghiep/park-map.tsx b/apps/web/components/khu-cong-nghiep/park-map.tsx index 042fd4e..005f646 100644 --- a/apps/web/components/khu-cong-nghiep/park-map.tsx +++ b/apps/web/components/khu-cong-nghiep/park-map.tsx @@ -9,6 +9,7 @@ import { PARK_STATUS_LABELS, PARK_STATUS_COLORS, } from '@/lib/khu-cong-nghiep-api'; +import { useMapboxStyle } from '@/lib/mapbox-style'; interface ParkMapProps { parks: IndustrialParkListItem[]; @@ -22,6 +23,7 @@ export function ParkMap({ parks, className }: ParkMapProps) { const mapContainerRef = React.useRef(null); const mapRef = React.useRef(null); const markersRef = React.useRef([]); + const mapStyle = useMapboxStyle(); const geoParks = React.useMemo( () => parks.filter((p) => p.latitude != null && p.longitude != null), @@ -38,7 +40,7 @@ export function ParkMap({ parks, className }: ParkMapProps) { const map = new mapboxgl.Map({ container: mapContainerRef.current, - style: 'mapbox://styles/mapbox/streets-v12', + style: mapStyle, center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, attributionControl: false, @@ -53,8 +55,15 @@ export function ParkMap({ parks, className }: ParkMapProps) { map.remove(); mapRef.current = null; }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + React.useEffect(() => { + const map = mapRef.current; + if (!map) return; + map.setStyle(mapStyle); + }, [mapStyle]); + React.useEffect(() => { const map = mapRef.current; if (!map) return; @@ -70,15 +79,16 @@ export function ParkMap({ parks, className }: ParkMapProps) { const el = document.createElement('div'); el.className = 'park-map-marker'; el.style.cssText = ` - background: white; + background: hsl(var(--card)); + color: hsl(var(--card-foreground)); border-radius: 8px; padding: 4px 8px; font-size: 11px; font-weight: 600; - box-shadow: 0 2px 6px rgba(0,0,0,0.15); + box-shadow: 0 2px 6px rgba(0,0,0,0.3); white-space: nowrap; cursor: pointer; - border-left: 3px solid hsl(221.2, 83.2%, 53.3%); + border-left: 3px solid hsl(var(--primary)); transition: transform 0.15s; max-width: 160px; overflow: hidden; @@ -109,15 +119,15 @@ export function ParkMap({ parks, className }: ParkMapProps) { const popup = new mapboxgl.Popup({ offset: 15, maxWidth: '260px', closeButton: false }) .setHTML( - `
+ `

${park.name}

-

${park.province} · ${park.totalAreaHa.toLocaleString()} ha

+

${park.province} · ${park.totalAreaHa.toLocaleString()} ha

${statusLabel} - ${rentText} + ${rentText}

-

Lấp đầy: ${park.occupancyRate}% · ${park.tenantCount} DN

- Xem chi tiết → +

Lấp đầy: ${park.occupancyRate}% · ${park.tenantCount} DN

+ Xem chi tiết →
`, ); diff --git a/apps/web/components/map/listing-map.tsx b/apps/web/components/map/listing-map.tsx index 3722bdc..4015366 100644 --- a/apps/web/components/map/listing-map.tsx +++ b/apps/web/components/map/listing-map.tsx @@ -5,6 +5,7 @@ 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); @@ -54,6 +55,7 @@ export function ListingMap({ listings, onMarkerClick, selectedListingId, classNa const mapRef = React.useRef(null); const markersRef = React.useRef([]); const popupRef = React.useRef(null); + const mapStyle = useMapboxStyle(); const markers: MapMarker[] = React.useMemo( () => listings.map((listing, index) => ({ listing, ...getMarkerCoords(listing, index) })), @@ -74,7 +76,7 @@ export function ListingMap({ listings, onMarkerClick, selectedListingId, classNa const map = new mapboxgl.Map({ container: mapContainerRef.current, - style: 'mapbox://styles/mapbox/streets-v12', + style: mapStyle, center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, attributionControl: false, @@ -89,8 +91,16 @@ export function ListingMap({ listings, onMarkerClick, selectedListingId, classNa 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; @@ -138,7 +148,7 @@ export function ListingMap({ listings, onMarkerClick, selectedListingId, classNa function buildPopupContent(listing: ListingDetail): HTMLDivElement { const container = document.createElement('div'); - container.style.fontFamily = 'system-ui,sans-serif'; + 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'); @@ -149,7 +159,7 @@ export function ListingMap({ listings, onMarkerClick, selectedListingId, classNa } const price = document.createElement('p'); - price.style.cssText = 'font-weight:700;color:hsl(142.1,76.2%,36.3%);font-size:14px;margin:0 0 4px;'; + 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); @@ -159,13 +169,13 @@ export function ListingMap({ listings, onMarkerClick, selectedListingId, classNa container.appendChild(title); const location = document.createElement('p'); - location.style.cssText = 'font-size:12px;color:#666;margin:0 0 8px;'; + 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:#f1f5f9;padding:2px 6px;border-radius:4px;'; + 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; @@ -188,7 +198,7 @@ export function ListingMap({ listings, onMarkerClick, selectedListingId, classNa 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(142.1,76.2%,36.3%);text-decoration:none;'; + 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); @@ -241,25 +251,26 @@ export function ListingMap({ listings, onMarkerClick, selectedListingId, classNa
diff --git a/apps/web/components/neighborhood/neighborhood-poi-map.tsx b/apps/web/components/neighborhood/neighborhood-poi-map.tsx index 8eeb25e..ca8f564 100644 --- a/apps/web/components/neighborhood/neighborhood-poi-map.tsx +++ b/apps/web/components/neighborhood/neighborhood-poi-map.tsx @@ -4,6 +4,7 @@ import mapboxgl from 'mapbox-gl'; import * as React from 'react'; import 'mapbox-gl/dist/mapbox-gl.css'; +import { useMapboxStyle } from '@/lib/mapbox-style'; import { cn } from '@/lib/utils'; import { type POIItem, type POICategory, POI_CATEGORY_CONFIG } from './types'; @@ -25,6 +26,7 @@ export function NeighborhoodPOIMap({ const mapContainerRef = React.useRef(null); const mapRef = React.useRef(null); const markersRef = React.useRef([]); + const mapStyle = useMapboxStyle(); const [activeCategories, setActiveCategories] = React.useState>( () => new Set(Object.keys(POI_CATEGORY_CONFIG) as POICategory[]), @@ -53,7 +55,7 @@ export function NeighborhoodPOIMap({ const map = new mapboxgl.Map({ container: mapContainerRef.current, - style: 'mapbox://styles/mapbox/streets-v12', + style: mapStyle, center: [center.lng, center.lat], zoom, attributionControl: false, @@ -71,8 +73,16 @@ export function NeighborhoodPOIMap({ 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 center when prop changes React.useEffect(() => { mapRef.current?.flyTo({ center: [center.lng, center.lat], zoom }); @@ -120,9 +130,9 @@ export function NeighborhoodPOIMap({ const popup = new mapboxgl.Popup({ offset: 20, closeButton: false }) .setHTML( - `
+ `

${config.icon} ${poi.name}

-

${config.label}${poi.distance ? ` · ${poi.distance}m` : ''}

+

${config.label}${poi.distance ? ` · ${poi.distance}m` : ''}

`, ); @@ -145,9 +155,9 @@ export function NeighborhoodPOIMap({ width: 16px; height: 16px; border-radius: 50%; - background: hsl(142.1, 76.2%, 36.3%); - border: 3px solid white; - box-shadow: 0 0 0 2px hsl(142.1, 76.2%, 36.3%), 0 2px 8px rgba(0,0,0,0.3); + background: hsl(var(--primary)); + border: 3px solid hsl(var(--card)); + box-shadow: 0 0 0 2px hsl(var(--primary)), 0 2px 8px rgba(0,0,0,0.3); `; const marker = new mapboxgl.Marker({ element: el, anchor: 'center' }) @@ -183,8 +193,8 @@ export function NeighborhoodPOIMap({ className={cn( 'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium shadow-sm transition-all', isActive - ? 'bg-white text-foreground ring-1 ring-inset ring-border' - : 'bg-white/60 text-muted-foreground line-through ring-1 ring-inset ring-transparent', + ? 'bg-card text-card-foreground ring-1 ring-inset ring-border' + : 'bg-card/60 text-muted-foreground line-through ring-1 ring-inset ring-transparent', )} title={`${isActive ? 'Ẩn' : 'Hiện'} ${config.label}`} > diff --git a/apps/web/components/valuation/__tests__/comparables-map.spec.tsx b/apps/web/components/valuation/__tests__/comparables-map.spec.tsx index 85f1267..e9af744 100644 --- a/apps/web/components/valuation/__tests__/comparables-map.spec.tsx +++ b/apps/web/components/valuation/__tests__/comparables-map.spec.tsx @@ -17,6 +17,8 @@ vi.mock('mapbox-gl', () => { fitBounds = mapFitBounds; flyTo = mapFlyTo; remove = mapRemove; + setStyle = () => undefined; + on = () => undefined; } class MockNavigationControl {} class MockAttributionControl {} diff --git a/apps/web/components/valuation/comparables-map.tsx b/apps/web/components/valuation/comparables-map.tsx index 77b9093..065c1bf 100644 --- a/apps/web/components/valuation/comparables-map.tsx +++ b/apps/web/components/valuation/comparables-map.tsx @@ -12,6 +12,7 @@ import { CardTitle, } from '@/components/ui/card'; import { formatPrice, formatPricePerM2 } from '@/lib/currency'; +import { useMapboxStyle } from '@/lib/mapbox-style'; import type { ValuationComparable } from '@/lib/valuation-api'; interface ComparablesMapProps { @@ -48,6 +49,7 @@ export function ComparablesMap({ const mapContainerRef = React.useRef(null); const mapRef = React.useRef(null); const markersRef = React.useRef([]); + const mapStyle = useMapboxStyle(); const geoComparables = React.useMemo( () => @@ -67,7 +69,7 @@ export function ComparablesMap({ const map = new mapboxgl.Map({ container: mapContainerRef.current, - style: 'mapbox://styles/mapbox/streets-v12', + style: mapStyle, center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, attributionControl: false, @@ -85,8 +87,15 @@ export function ComparablesMap({ map.remove(); mapRef.current = null; }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + React.useEffect(() => { + const map = mapRef.current; + if (!map) return; + map.setStyle(mapStyle); + }, [mapStyle]); + React.useEffect(() => { const map = mapRef.current; if (!map) return; diff --git a/apps/web/lib/du-an-server.ts b/apps/web/lib/du-an-server.ts index 95841de..1594d2a 100644 --- a/apps/web/lib/du-an-server.ts +++ b/apps/web/lib/du-an-server.ts @@ -42,23 +42,61 @@ function normalizeProjectDetail(raw: unknown): ProjectDetail | null { }; // Media: may be absent (stripped by backend), or present as a raw JSON array. - const rawMedia = Array.isArray(r['media']) ? (r['media'] as Record[]) : []; - const media: ProjectMedia[] = rawMedia.map((m, idx) => ({ - id: typeof m['id'] === 'string' ? m['id'] : `media-${idx}`, - url: typeof m['url'] === 'string' ? m['url'] : '', - type: - m['type'] === 'video' || - m['type'] === 'master_plan' || - m['type'] === 'document' || - m['type'] === 'image' - ? (m['type'] as ProjectMedia['type']) - : 'image', - order: typeof m['order'] === 'number' ? m['order'] : idx, - caption: typeof m['caption'] === 'string' ? m['caption'] : null, - })); + // Backend returns media as either `string[]` (raw URLs from the seed JSON + // column) or `{ id, url, type, order, caption }[]` once richer data lands. + // Handle both shapes and drop entries without a resolvable URL so we never + // feed an empty string to ``. + const rawMediaArr = Array.isArray(r['media']) ? (r['media'] as unknown[]) : []; + const media: ProjectMedia[] = rawMediaArr + .map((entry, idx): ProjectMedia | null => { + if (typeof entry === 'string') { + if (!entry) return null; + return { id: `media-${idx}`, url: entry, type: 'image', order: idx, caption: null }; + } + if (entry && typeof entry === 'object') { + const m = entry as Record; + const url = typeof m['url'] === 'string' ? m['url'] : ''; + if (!url) return null; + return { + id: typeof m['id'] === 'string' ? m['id'] : `media-${idx}`, + url, + type: + m['type'] === 'video' || + m['type'] === 'master_plan' || + m['type'] === 'document' || + m['type'] === 'image' + ? (m['type'] as ProjectMedia['type']) + : 'image', + order: typeof m['order'] === 'number' ? m['order'] : idx, + caption: typeof m['caption'] === 'string' ? m['caption'] : null, + }; + } + return null; + }) + .filter((m): m is ProjectMedia => m !== null); const asArray = (v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []); + // Amenities in the DB are a JSON string[]; the frontend type expects + // `{ id, name, icon, category }`. Normalize strings into objects so the + // AmenitiesTab render has stable keys + a displayable name. + const rawAmenities = Array.isArray(r['amenities']) ? (r['amenities'] as unknown[]) : []; + const amenities = rawAmenities.map((a, idx) => { + if (typeof a === 'string') { + return { id: `amenity-${idx}`, name: a, icon: '', category: 'Tiện ích' }; + } + if (a && typeof a === 'object') { + const o = a as Record; + return { + id: typeof o['id'] === 'string' ? o['id'] : `amenity-${idx}`, + name: typeof o['name'] === 'string' ? o['name'] : '', + icon: typeof o['icon'] === 'string' ? o['icon'] : '', + category: typeof o['category'] === 'string' ? o['category'] : 'Tiện ích', + }; + } + return { id: `amenity-${idx}`, name: '', icon: '', category: 'Tiện ích' }; + }); + return { id: String(r['id'] ?? ''), slug: String(r['slug'] ?? ''), @@ -84,7 +122,7 @@ function normalizeProjectDetail(raw: unknown): ProjectDetail | null { description: typeof r['description'] === 'string' ? (r['description'] as string) : '', media, blocks: asArray(r['blocks']), - amenities: asArray(r['amenities']), + amenities, priceRanges: asArray(r['priceRanges']), priceHistory: asArray(r['priceHistory']), neighborhoodScores: asArray(r['neighborhoodScores']), diff --git a/apps/web/lib/mapbox-style.ts b/apps/web/lib/mapbox-style.ts new file mode 100644 index 0000000..d1f3ee2 --- /dev/null +++ b/apps/web/lib/mapbox-style.ts @@ -0,0 +1,16 @@ +'use client'; + +import { useTheme } from '@/components/providers/theme-provider'; + +export const MAPBOX_STYLE_LIGHT = 'mapbox://styles/mapbox/streets-v12'; +export const MAPBOX_STYLE_DARK = 'mapbox://styles/mapbox/dark-v11'; + +/** + * Resolve the Mapbox style URL for the current app theme. Call this inside + * the map component and pass the result to `new mapboxgl.Map({ style })` AND + * call `map.setStyle(style)` whenever it changes. + */ +export function useMapboxStyle(): string { + const { theme } = useTheme(); + return theme === 'dark' ? MAPBOX_STYLE_DARK : MAPBOX_STYLE_LIGHT; +}