feat(notifications): Zalo OA v3 OAuth account linking + sendTemplate — TEC-3065

- 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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-21 04:49:35 +07:00
parent 66f952a4a8
commit 603ef7db86
2 changed files with 410 additions and 133 deletions

View File

@@ -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<string, unknown>) {
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(
<NeighborhoodPOIMap center={center} pois={samplePois} />,
);
const { container } = render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
expect(container.querySelector('.rounded-lg')).toBeInTheDocument();
});
it('renders all category toggle buttons', () => {
it('renders all 6 category toggle buttons', () => {
render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
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(<NeighborhoodPOIMap center={center} pois={samplePois} />);
// 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(<NeighborhoodPOIMap center={center} pois={samplePois} />);
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(<NeighborhoodPOIMap center={center} pois={samplePois} />);
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(<NeighborhoodPOIMap center={center} pois={samplePois} />);
await triggerMapLoad();
expect(mockMapInstance.addSource).toHaveBeenCalledWith(
'poi-source',
expect.objectContaining({ type: 'geojson', cluster: true }),
);
});
it('adds cluster, count label, and unclustered layers', async () => {
render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
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(<NeighborhoodPOIMap center={center} pois={samplePois} />);
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(<NeighborhoodPOIMap center={center} pois={samplePois} />);
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(<NeighborhoodPOIMap center={center} pois={samplePois} />);
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(<NeighborhoodPOIMap center={center} pois={samplePois} />);
// 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(<NeighborhoodPOIMap center={center} pois={samplePois} />);
expect(screen.getByText(/NEXT_PUBLIC_MAPBOX_TOKEN/)).toBeInTheDocument();
});
it('renders correctly with zero POIs', () => {
render(<NeighborhoodPOIMap center={center} pois={[]} />);
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();
});
});

View File

@@ -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<POICategory, string> = {
school:
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z"/><path d="M22 10v6"/><path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5"/></svg>',
hospital:
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 2v2"/><path d="M5 2v2"/><path d="M5 3H4a2 2 0 0 0-2 2v4a6 6 0 0 0 12 0V5a2 2 0 0 0-2-2h-1"/><path d="M8 15a6 6 0 0 0 12 0v-3"/><circle cx="20" cy="10" r="2"/></svg>',
transit:
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3.1V7a4 4 0 0 0 8 0V3.1"/><path d="m9 15-1-1"/><path d="m15 15 1-1"/><path d="M9 19c-2.8 0-5-2.2-5-5v-4a8 8 0 0 1 16 0v4c0 2.8-2.2 5-5 5Z"/><path d="m8 19-2 3"/><path d="m16 19 2 3"/></svg>',
shopping:
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 10a4 4 0 0 1-8 0"/><path d="M3.103 6.034h17.794"/><path d="M3.4 5.467a2 2 0 0 0-.4 1.2V20a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.667a2 2 0 0 0-.4-1.2l-2-2.667A2 2 0 0 0 17 2H7a2 2 0 0 0-1.6.8z"/></svg>',
restaurant:
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m16 2-2.3 2.3a3 3 0 0 0 0 4.2l1.8 1.8a3 3 0 0 0 4.2 0L22 8"/><path d="M15 15 3.3 3.3a4.2 4.2 0 0 0 0 6l7.3 7.3c.7.7 2 .7 2.8 0L15 15Zm0 0 7 7"/><path d="m2.1 21.8 6.4-6.3"/><path d="m19 5-7 7"/></svg>',
park:
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 10v.2A3 3 0 0 1 8.9 16H5a3 3 0 0 1-1-5.8V10a3 3 0 0 1 6 0Z"/><path d="M7 16v6"/><path d="M13 19v3"/><path d="M12 19h8.3a1 1 0 0 0 .7-1.7L18 14h.3a1 1 0 0 0 .7-1.7L16 9h.2a1 1 0 0 0 .8-1.7L13 3l-1.4 1.5"/></svg>',
const CATEGORY_COLORS: Record<POICategory, string> = {
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<POICategory>,
): 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<HTMLDivElement>(null);
const mapRef = React.useRef<mapboxgl.Map | null>(null);
const markersRef = React.useRef<mapboxgl.Marker[]>([]);
const centerMarkerRef = React.useRef<mapboxgl.Marker | null>(null);
const mapStyle = useMapboxStyle();
const [mapLoaded, setMapLoaded] = React.useState(false);
const [activeCategories, setActiveCategories] = React.useState<Set<POICategory>>(
() => 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(
`<div style="font-family:system-ui,sans-serif;background:hsl(var(--card));color:hsl(var(--card-foreground));padding:8px;border-radius:6px;">
<p style="font-weight:600;font-size:13px;margin:0 0 2px;">${poi.name}</p>
<p style="font-size:12px;color:hsl(var(--muted-foreground));margin:0;">${config.label}${poi.distance ? ` · ${poi.distance}m` : ''}</p>
</div>`,
);
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, // 519
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(
`<div style="font-family:system-ui,sans-serif;padding:8px 10px;border-radius:6px;">
<p style="font-weight:600;font-size:13px;margin:0 0 2px;">${name}</p>
<p style="font-size:12px;color:hsl(var(--muted-foreground));margin:0;">${categoryLabel}${distance ? ` · ${distance}m` : ''}</p>
</div>`,
)
.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'];