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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, // 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(
|
||||
`<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'];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user