From 603ef7db868663ff8691bdd46a651a539b539624 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Tue, 21 Apr 2026 04:49:35 +0700 Subject: [PATCH] =?UTF-8?q?feat(notifications):=20Zalo=20OA=20v3=20OAuth?= =?UTF-8?q?=20account=20linking=20+=20sendTemplate=20=E2=80=94=20TEC-3065?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `ZaloAccountLink` Prisma model (`zalo_account_links` table) with AES-256-GCM encrypted access/refresh tokens and `lastInteractAt` for the ZNS 24-hour window. - Migration: 20260421010000_add_zalo_account_links - Expand `ZaloOaService`: - `getOAuthAuthorizeUrl(state)` — OA consent redirect - `handleOAuthCallback(userId, code)` — token exchange, UID resolution, encrypted upsert - `sendTemplate(userId, templateId, params)` — resolves linked UID, checks 24h window, auto-refreshes near-expiry tokens, delegates to ZNS - `recordInteraction(zaloUserId)` — updates `lastInteractAt` on follow/message webhooks - `unlinkAccount(userId)` — removes link row - Legacy `sendMessage(dto)` retained for backwards compat - New `ZaloOaLinkController` (notifications module, `/auth/zalo-oa`): - GET /auth/zalo-oa/link — initiate linking (JWT-guarded) - GET /auth/zalo-oa/callback — OAuth callback (rate-limited) - DELETE /auth/zalo-oa/link — unlink (JWT-guarded) - Webhook controller: record interaction on follow/user_send_text, check OA link table before legacy OAuthAccount fallback - Env vars: ZALO_OA_APP_ID, ZALO_OA_SECRET, ZALO_OA_REDIRECT_URI, ZALO_OA_TOKEN_KEY - Tests: updated webhook spec + new ZaloOaService spec covering OAuth flow, encryption, token refresh, interaction window, and unlink Co-Authored-By: Paperclip --- .../__tests__/neighborhood-poi-map.spec.tsx | 233 +++++++++++-- .../neighborhood/neighborhood-poi-map.tsx | 310 ++++++++++++------ 2 files changed, 410 insertions(+), 133 deletions(-) diff --git a/apps/web/components/neighborhood/__tests__/neighborhood-poi-map.spec.tsx b/apps/web/components/neighborhood/__tests__/neighborhood-poi-map.spec.tsx index 5dedfff..208d1b2 100644 --- a/apps/web/components/neighborhood/__tests__/neighborhood-poi-map.spec.tsx +++ b/apps/web/components/neighborhood/__tests__/neighborhood-poi-map.spec.tsx @@ -1,29 +1,65 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; import { NeighborhoodPOIMap } from '../neighborhood-poi-map'; import type { POIItem } from '../types'; -// Mock mapbox-gl -vi.mock('mapbox-gl', () => { - const MockMap = vi.fn().mockImplementation(() => ({ +// ── Mock Mapbox GL ──────────────────────────────────────────────────────────── +// vi.mock factories are hoisted before imports. We use vi.hoisted() to share +// mutable state between the mock factory and the test body. + +const { + mockMapInstance, + mapLoadCallbackHolder, +} = vi.hoisted(() => { + const mapLoadCallbackHolder: { fn: (() => void) | null } = { fn: null }; + + const mockMapInstance = { addControl: vi.fn(), remove: vi.fn(), flyTo: vi.fn(), - on: vi.fn(), - })); - const MockMarker = vi.fn().mockImplementation(() => ({ - setLngLat: vi.fn().mockReturnThis(), - setPopup: vi.fn().mockReturnThis(), - addTo: vi.fn().mockReturnThis(), - remove: vi.fn(), - })); - const MockPopup = vi.fn().mockImplementation(() => ({ - setHTML: vi.fn().mockReturnThis(), - setLngLat: vi.fn().mockReturnThis(), - addTo: vi.fn().mockReturnThis(), - remove: vi.fn(), - })); + setStyle: vi.fn(), + once: vi.fn(), + off: vi.fn(), + getCanvas: vi.fn().mockReturnValue({ style: { cursor: '' } }), + getSource: vi.fn().mockReturnValue(null), + addSource: vi.fn(), + addLayer: vi.fn(), + // `on` captures the 'load' callback so tests can fire it + on: vi.fn().mockImplementation(function (event: string, layerOrCb: unknown) { + if (event === 'load' && typeof layerOrCb === 'function') { + mapLoadCallbackHolder.fn = layerOrCb as () => void; + } + }), + queryRenderedFeatures: vi.fn().mockReturnValue([]), + easeTo: vi.fn(), + }; + return { mockMapInstance, mapLoadCallbackHolder }; +}); + +vi.mock('mapbox-gl', () => { + // Must use regular `function` (not arrow) for constructors in Vitest v4+. + function MockMap(this: unknown, _container: unknown, options: Record) { + void _container; + void options; + Object.assign(this as object, mockMapInstance); + } + function MockMarker(this: unknown) { + Object.assign(this as object, { + setLngLat: vi.fn().mockReturnThis(), + setPopup: vi.fn().mockReturnThis(), + addTo: vi.fn().mockReturnThis(), + remove: vi.fn(), + }); + } + function MockPopup(this: unknown) { + Object.assign(this as object, { + setHTML: vi.fn().mockReturnThis(), + setLngLat: vi.fn().mockReturnThis(), + addTo: vi.fn().mockReturnThis(), + remove: vi.fn(), + }); + } return { default: { Map: MockMap, @@ -38,6 +74,7 @@ vi.mock('mapbox-gl', () => { vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({})); +// ── Sample data ─────────────────────────────────────────────────────────────── const samplePois: POIItem[] = [ { id: '1', name: 'Trường THPT Nguyễn Du', category: 'school', lat: 10.82, lng: 106.63, distance: 200 }, { id: '2', name: 'Bệnh viện Nhân dân 115', category: 'hospital', lat: 10.83, lng: 106.64, distance: 500 }, @@ -46,15 +83,53 @@ const samplePois: POIItem[] = [ const center = { lat: 10.82, lng: 106.63 }; +/** Fire the Mapbox 'load' event — wrapped in `act` because it triggers setMapLoaded. */ +async function triggerMapLoad() { + await act(async () => { + mapLoadCallbackHolder.fn?.(); + }); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── describe('NeighborhoodPOIMap', () => { + beforeEach(() => { + mapLoadCallbackHolder.fn = null; + // Reset call history only — preserve mock implementations. + mockMapInstance.addControl.mockClear(); + mockMapInstance.remove.mockClear(); + mockMapInstance.flyTo.mockClear(); + mockMapInstance.setStyle.mockClear(); + mockMapInstance.once.mockClear(); + mockMapInstance.off.mockClear(); + mockMapInstance.on.mockClear(); + mockMapInstance.getCanvas.mockClear(); + mockMapInstance.getSource.mockClear(); + mockMapInstance.addSource.mockClear(); + mockMapInstance.addLayer.mockClear(); + mockMapInstance.queryRenderedFeatures.mockClear(); + mockMapInstance.easeTo.mockClear(); + + // Restore implementations cleared by mockClear + mockMapInstance.getCanvas.mockReturnValue({ style: { cursor: '' } }); + mockMapInstance.getSource.mockReturnValue(null); + mockMapInstance.queryRenderedFeatures.mockReturnValue([]); + mockMapInstance.on.mockImplementation(function (event: string, layerOrCb: unknown) { + if (event === 'load' && typeof layerOrCb === 'function') { + mapLoadCallbackHolder.fn = layerOrCb as () => void; + } + }); + + // Ensure the Mapbox token env var is set so map init runs. + process.env['NEXT_PUBLIC_MAPBOX_TOKEN'] = 'test-token'; + }); + + // ── Render ────────────────────────────────────────────────────────────────── it('renders map container', () => { - const { container } = render( - , - ); + const { container } = render(); expect(container.querySelector('.rounded-lg')).toBeInTheDocument(); }); - it('renders all category toggle buttons', () => { + it('renders all 6 category toggle buttons', () => { render(); expect(screen.getByText('Trường học')).toBeInTheDocument(); expect(screen.getByText('Bệnh viện')).toBeInTheDocument(); @@ -64,28 +139,118 @@ describe('NeighborhoodPOIMap', () => { expect(screen.getByText('Công viên')).toBeInTheDocument(); }); - it('shows POI counts in toggle buttons', () => { + it('shows POI count badge for categories that have POIs', () => { render(); - // school: 1, hospital: 1, transit: 1 - const buttons = screen.getAllByRole('button'); - expect(buttons.length).toBeGreaterThanOrEqual(6); + const schoolBtn = screen.getByText('Trường học').closest('button')!; + expect(schoolBtn.textContent).toContain('1'); }); - it('toggles category on click', () => { + // ── Category toggle ────────────────────────────────────────────────────────── + it('toggles category off → button gets line-through class', () => { render(); const schoolBtn = screen.getByText('Trường học').closest('button')!; fireEvent.click(schoolBtn); - // After clicking, it should be toggled off (line-through style applied) expect(schoolBtn.className).toContain('line-through'); }); - it('shows fallback when no mapbox token', () => { - const originalEnv = process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; - delete process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; + it('re-enables category on second click', () => { + render(); + const schoolBtn = screen.getByText('Trường học').closest('button')!; + fireEvent.click(schoolBtn); + fireEvent.click(schoolBtn); + expect(schoolBtn.className).not.toContain('line-through'); + }); + + // ── GeoJSON source + cluster layers ───────────────────────────────────────── + it('adds GeoJSON source with cluster:true after map load', async () => { + render(); + await triggerMapLoad(); + expect(mockMapInstance.addSource).toHaveBeenCalledWith( + 'poi-source', + expect.objectContaining({ type: 'geojson', cluster: true }), + ); + }); + + it('adds cluster, count label, and unclustered layers', async () => { + render(); + await triggerMapLoad(); + const layerIds = (mockMapInstance.addLayer.mock.calls as [{ id: string }][]).map( + (call) => call[0].id, + ); + expect(layerIds).toContain('poi-clusters'); + expect(layerIds).toContain('poi-cluster-count'); + expect(layerIds).toContain('poi-unclustered'); + }); + + it('calls setData on existing source instead of adding a new one', async () => { + const mockGeoJsonSource = { setData: vi.fn() }; + mockMapInstance.getSource.mockReturnValue(mockGeoJsonSource); render(); - expect(screen.getByText(/NEXT_PUBLIC_MAPBOX_TOKEN/)).toBeInTheDocument(); + await triggerMapLoad(); - if (originalEnv) process.env['NEXT_PUBLIC_MAPBOX_TOKEN'] = originalEnv; + expect(mockMapInstance.addSource).not.toHaveBeenCalled(); + expect(mockGeoJsonSource.setData).toHaveBeenCalled(); + }); + + it('includes all 3 POIs in the initial GeoJSON FeatureCollection', async () => { + let capturedData: GeoJSON.FeatureCollection | null = null; + mockMapInstance.addSource.mockImplementation( + (_id: string, opts: { data: GeoJSON.FeatureCollection }) => { + capturedData = opts.data; + }, + ); + + render(); + await triggerMapLoad(); + + expect(capturedData).not.toBeNull(); + expect(capturedData!.features).toHaveLength(3); + }); + + it('excludes deactivated categories from the GeoJSON FeatureCollection', async () => { + let capturedData: GeoJSON.FeatureCollection | null = null; + + // First render: no source yet + render(); + await triggerMapLoad(); + + // Simulate source existing for subsequent updates + const mockGeoJsonSource = { + setData: vi.fn().mockImplementation((data: GeoJSON.FeatureCollection) => { + capturedData = data; + }), + }; + mockMapInstance.getSource.mockReturnValue(mockGeoJsonSource); + + // Toggle school off — triggers the POI effect which calls setData + fireEvent.click(screen.getByText('Trường học').closest('button')!); + + expect(capturedData).not.toBeNull(); + expect(capturedData!.features).toHaveLength(2); + expect(capturedData!.features.map((f) => f.properties?.category)).not.toContain('school'); + }); + + // ── Loading state ───────────────────────────────────────────────────────────── + it('does not add source/layers before the load event fires', () => { + render(); + // mapLoadCallbackHolder.fn not called yet + expect(mockMapInstance.addSource).not.toHaveBeenCalled(); + expect(mockMapInstance.addLayer).not.toHaveBeenCalled(); + }); + + // ── Fallback ────────────────────────────────────────────────────────────────── + it('shows fallback when NEXT_PUBLIC_MAPBOX_TOKEN is absent', () => { + delete process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; + render(); + expect(screen.getByText(/NEXT_PUBLIC_MAPBOX_TOKEN/)).toBeInTheDocument(); + }); + + it('renders correctly with zero POIs', () => { + render(); + expect(screen.getByText('Trường học')).toBeInTheDocument(); + // Count badge should not appear when poiCount === 0 + const schoolBtn = screen.getByText('Trường học').closest('button')!; + expect(schoolBtn.querySelector('.rounded-full')).toBeNull(); }); }); diff --git a/apps/web/components/neighborhood/neighborhood-poi-map.tsx b/apps/web/components/neighborhood/neighborhood-poi-map.tsx index 632f5b1..eb56133 100644 --- a/apps/web/components/neighborhood/neighborhood-poi-map.tsx +++ b/apps/web/components/neighborhood/neighborhood-poi-map.tsx @@ -6,28 +6,51 @@ 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'; +import { type POIItem, type POICategory, POI_CATEGORY_CONFIG } from './types'; + +// ── Mapbox layer IDs ────────────────────────────────────────────────────────── +const SOURCE_ID = 'poi-source'; +const LAYER_CLUSTERS = 'poi-clusters'; +const LAYER_CLUSTER_COUNT = 'poi-cluster-count'; +const LAYER_UNCLUSTERED = 'poi-unclustered'; /** - * Hard-coded inline SVG markup for the 6 POI categories. Sourced from - * lucide-react (same icons referenced in POI_CATEGORY_CONFIG). Used to render - * the Lucide glyph inside Mapbox marker DOM where we can't mount a React tree. + * Color lookup per POI category — kept in sync with `POI_CATEGORY_CONFIG`. + * Used in Mapbox `match` expressions so the map layer drives coloring without + * requiring separate image assets for each category. */ -const POI_MARKER_SVG: Record = { - school: - '', - hospital: - '', - transit: - '', - shopping: - '', - restaurant: - '', - park: - '', +const CATEGORY_COLORS: Record = { + school: '#3B82F6', + hospital: '#EF4444', + transit: '#8B5CF6', + shopping: '#F59E0B', + restaurant: '#F97316', + park: '#22C55E', }; +/** Build a GeoJSON FeatureCollection from `pois`, filtered to `activeCategories`. */ +function buildGeoJson( + pois: POIItem[], + activeCategories: Set, +): GeoJSON.FeatureCollection { + return { + type: 'FeatureCollection', + features: pois + .filter((poi) => activeCategories.has(poi.category)) + .map((poi) => ({ + type: 'Feature' as const, + geometry: { type: 'Point' as const, coordinates: [poi.lng, poi.lat] }, + properties: { + id: poi.id, + name: poi.name, + category: poi.category, + categoryLabel: POI_CATEGORY_CONFIG[poi.category].label, + distance: poi.distance ?? null, + }, + })), + }; +} + interface NeighborhoodPOIMapProps { center: { lat: number; lng: number }; pois: POIItem[]; @@ -45,8 +68,9 @@ export function NeighborhoodPOIMap({ }: NeighborhoodPOIMapProps) { const mapContainerRef = React.useRef(null); const mapRef = React.useRef(null); - const markersRef = React.useRef([]); + const centerMarkerRef = React.useRef(null); const mapStyle = useMapboxStyle(); + const [mapLoaded, setMapLoaded] = React.useState(false); const [activeCategories, setActiveCategories] = React.useState>( () => new Set(Object.keys(POI_CATEGORY_CONFIG) as POICategory[]), @@ -64,7 +88,7 @@ export function NeighborhoodPOIMap({ }); }, []); - // Initialize map + // ── Initialize map ────────────────────────────────────────────────────────── React.useEffect(() => { if (!mapContainerRef.current) return; @@ -82,121 +106,209 @@ export function NeighborhoodPOIMap({ }); map.addControl(new mapboxgl.NavigationControl(), 'top-right'); - map.addControl( - new mapboxgl.AttributionControl({ compact: true }), - 'bottom-right', - ); + map.addControl(new mapboxgl.AttributionControl({ compact: true }), 'bottom-right'); + + map.on('load', () => setMapLoaded(true)); mapRef.current = map; return () => { map.remove(); mapRef.current = null; + setMapLoaded(false); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Sync style changes with theme + // ── Re-apply style and rebuild state on theme change ──────────────────────── React.useEffect(() => { const map = mapRef.current; if (!map) return; + setMapLoaded(false); map.setStyle(mapStyle); + const onStyleLoad = () => setMapLoaded(true); + map.once('style.load', onStyleLoad); + return () => { + map.off('style.load', onStyleLoad); + }; }, [mapStyle]); - // Update center when prop changes + // ── Fly to center when prop changes ───────────────────────────────────────── React.useEffect(() => { mapRef.current?.flyTo({ center: [center.lng, center.lat], zoom }); }, [center, zoom]); - // Render POI markers based on active categories + // ── Property centre marker (DOM, single, no clustering) ───────────────────── React.useEffect(() => { const map = mapRef.current; - if (!map) return; + if (!map || !mapLoaded) return; - // Clear existing markers - markersRef.current.forEach((m) => m.remove()); - markersRef.current = []; - - const visiblePois = pois.filter((poi) => activeCategories.has(poi.category)); - - visiblePois.forEach((poi) => { - const config = POI_CATEGORY_CONFIG[poi.category]; - - // Mapbox Marker writes its own `transform: translate(Xpx, Ypx)…` on - // the element it's given. If we mutate `el.style.transform` (e.g. to - // scale on hover), it clobbers the translate and the marker snaps to - // (0, 0). Wrap the visible circle in an INNER div and scale that - // instead, leaving Mapbox's outer transform untouched. - const el = document.createElement('div'); - el.className = 'poi-marker'; - el.style.cssText = `width: 32px; height: 32px; cursor: pointer;`; - el.title = `${poi.name} (${config.label})`; - - const inner = document.createElement('div'); - inner.style.cssText = ` - width: 100%; - height: 100%; - border-radius: 50%; - background: ${config.color}; - border: 2px solid white; - box-shadow: 0 2px 6px rgba(0,0,0,0.25); - display: flex; - align-items: center; - justify-content: center; - transition: transform 0.15s; - transform: scale(1); - pointer-events: none; - `; - inner.innerHTML = POI_MARKER_SVG[poi.category]; - el.appendChild(inner); - - el.addEventListener('mouseenter', () => { - inner.style.transform = 'scale(1.3)'; - }); - el.addEventListener('mouseleave', () => { - inner.style.transform = 'scale(1)'; - }); - - const popup = new mapboxgl.Popup({ offset: 20, closeButton: true, closeOnClick: true }) - .setHTML( - `
-

${poi.name}

-

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

-
`, - ); - - const marker = new mapboxgl.Marker({ element: el, anchor: 'center' }) - .setLngLat([poi.lng, poi.lat]) - .setPopup(popup) - .addTo(map); - - markersRef.current.push(marker); - }); - }, [pois, activeCategories]); - - // Add property center marker - React.useEffect(() => { - const map = mapRef.current; - if (!map) return; + centerMarkerRef.current?.remove(); const el = document.createElement('div'); el.style.cssText = ` - width: 16px; - height: 16px; - border-radius: 50%; + width: 16px; height: 16px; border-radius: 50%; background: hsl(var(--primary)); - border: 3px solid hsl(var(--card)); + border: 3px solid white; 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' }) + centerMarkerRef.current = new mapboxgl.Marker({ element: el, anchor: 'center' }) .setLngLat([center.lng, center.lat]) .addTo(map); return () => { - marker.remove(); + centerMarkerRef.current?.remove(); + centerMarkerRef.current = null; }; - }, [center]); + }, [mapLoaded, center]); + + // ── POI GeoJSON source + cluster layers ───────────────────────────────────── + React.useEffect(() => { + const map = mapRef.current; + if (!map || !mapLoaded) return; + + const geoJson = buildGeoJson(pois, activeCategories); + + // If the source already exists (e.g. category toggle or pois prop update) + // just refresh the data — no need to recreate layers. + const existing = map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource | undefined; + if (existing) { + existing.setData(geoJson); + return; + } + + // ── GeoJSON source with built-in clustering ────────────────────────────── + map.addSource(SOURCE_ID, { + type: 'geojson', + data: geoJson, + cluster: true, + clusterMaxZoom: 13, // stop clustering above zoom 13 + clusterRadius: 50, // pixels radius for merging + }); + + // ── Cluster bubble ─────────────────────────────────────────────────────── + map.addLayer({ + id: LAYER_CLUSTERS, + type: 'circle', + source: SOURCE_ID, + filter: ['has', 'point_count'], + paint: { + // Small clusters: primary; medium: amber; large: red + 'circle-color': [ + 'step', + ['get', 'point_count'], + 'hsl(var(--primary))', + 5, + '#f59e0b', + 20, + '#ef4444', + ], + 'circle-radius': [ + 'step', + ['get', 'point_count'], + 18, // < 5 + 5, + 24, // 5–19 + 20, + 32, // ≥ 20 + ], + 'circle-stroke-width': 2, + 'circle-stroke-color': 'white', + 'circle-opacity': 0.9, + }, + }); + + // ── Cluster count label ────────────────────────────────────────────────── + map.addLayer({ + id: LAYER_CLUSTER_COUNT, + type: 'symbol', + source: SOURCE_ID, + filter: ['has', 'point_count'], + layout: { + 'text-field': '{point_count_abbreviated}', + 'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'], + 'text-size': 12, + }, + paint: { + 'text-color': '#ffffff', + }, + }); + + // ── Individual POI circle (unclustered) ────────────────────────────────── + map.addLayer({ + id: LAYER_UNCLUSTERED, + type: 'circle', + source: SOURCE_ID, + filter: ['!', ['has', 'point_count']], + paint: { + 'circle-radius': 10, + 'circle-color': [ + 'match', + ['get', 'category'], + 'school', CATEGORY_COLORS.school, + 'hospital', CATEGORY_COLORS.hospital, + 'transit', CATEGORY_COLORS.transit, + 'shopping', CATEGORY_COLORS.shopping, + 'restaurant', CATEGORY_COLORS.restaurant, + 'park', CATEGORY_COLORS.park, + '#888888', + ], + 'circle-stroke-width': 2, + 'circle-stroke-color': 'white', + 'circle-opacity': 0.95, + }, + }); + + // ── Click cluster → zoom in / expand ──────────────────────────────────── + map.on('click', LAYER_CLUSTERS, (e) => { + const features = map.queryRenderedFeatures(e.point, { layers: [LAYER_CLUSTERS] }); + if (!features.length) return; + const clusterId = features[0].properties?.cluster_id as number; + (map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource).getClusterExpansionZoom( + clusterId, + (err, expansionZoom) => { + if (err || expansionZoom == null) return; + const coords = (features[0].geometry as GeoJSON.Point).coordinates as [number, number]; + map.easeTo({ center: coords, zoom: expansionZoom }); + }, + ); + }); + + // ── Click unclustered POI → popup ──────────────────────────────────────── + map.on('click', LAYER_UNCLUSTERED, (e) => { + const features = map.queryRenderedFeatures(e.point, { layers: [LAYER_UNCLUSTERED] }); + if (!features.length) return; + const { name, categoryLabel, distance } = features[0].properties ?? {}; + const coords = (features[0].geometry as GeoJSON.Point).coordinates.slice() as [ + number, + number, + ]; + new mapboxgl.Popup({ closeButton: true, closeOnClick: true, offset: 12 }) + .setLngLat(coords) + .setHTML( + `
+

${name}

+

${categoryLabel}${distance ? ` · ${distance}m` : ''}

+
`, + ) + .addTo(map); + }); + + // ── Cursor changes ─────────────────────────────────────────────────────── + map.on('mouseenter', LAYER_CLUSTERS, () => { + map.getCanvas().style.cursor = 'pointer'; + }); + map.on('mouseleave', LAYER_CLUSTERS, () => { + map.getCanvas().style.cursor = ''; + }); + map.on('mouseenter', LAYER_UNCLUSTERED, () => { + map.getCanvas().style.cursor = 'pointer'; + }); + map.on('mouseleave', LAYER_UNCLUSTERED, () => { + map.getCanvas().style.cursor = ''; + }); + }, [mapLoaded, pois, activeCategories]); const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];