- 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>
396 lines
14 KiB
TypeScript
396 lines
14 KiB
TypeScript
'use client';
|
||
|
||
/* eslint-disable import-x/no-named-as-default-member */
|
||
import mapboxgl from 'mapbox-gl';
|
||
import * as React from 'react';
|
||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||
import { useMapboxStyle } from '@/lib/mapbox-style';
|
||
import { cn } from '@/lib/utils';
|
||
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';
|
||
|
||
/**
|
||
* 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 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[];
|
||
zoom?: number;
|
||
height?: string;
|
||
className?: string;
|
||
}
|
||
|
||
export function NeighborhoodPOIMap({
|
||
center,
|
||
pois,
|
||
zoom = 14,
|
||
height = '400px',
|
||
className,
|
||
}: NeighborhoodPOIMapProps) {
|
||
const mapContainerRef = React.useRef<HTMLDivElement>(null);
|
||
const mapRef = React.useRef<mapboxgl.Map | null>(null);
|
||
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[]),
|
||
);
|
||
|
||
const toggleCategory = React.useCallback((category: POICategory) => {
|
||
setActiveCategories((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(category)) {
|
||
next.delete(category);
|
||
} else {
|
||
next.add(category);
|
||
}
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
// ── Initialize map ──────────────────────────────────────────────────────────
|
||
React.useEffect(() => {
|
||
if (!mapContainerRef.current) return;
|
||
|
||
const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
|
||
if (!token) return;
|
||
|
||
mapboxgl.accessToken = token;
|
||
|
||
const map = new mapboxgl.Map({
|
||
container: mapContainerRef.current,
|
||
style: mapStyle,
|
||
center: [center.lng, center.lat],
|
||
zoom,
|
||
attributionControl: false,
|
||
});
|
||
|
||
map.addControl(new mapboxgl.NavigationControl(), 'top-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
|
||
}, []);
|
||
|
||
// ── 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]);
|
||
|
||
// ── Fly to center when prop changes ─────────────────────────────────────────
|
||
React.useEffect(() => {
|
||
mapRef.current?.flyTo({ center: [center.lng, center.lat], zoom });
|
||
}, [center, zoom]);
|
||
|
||
// ── Property centre marker (DOM, single, no clustering) ─────────────────────
|
||
React.useEffect(() => {
|
||
const map = mapRef.current;
|
||
if (!map || !mapLoaded) return;
|
||
|
||
centerMarkerRef.current?.remove();
|
||
|
||
const el = document.createElement('div');
|
||
el.style.cssText = `
|
||
width: 16px; height: 16px; border-radius: 50%;
|
||
background: hsl(var(--primary));
|
||
border: 3px solid white;
|
||
box-shadow: 0 0 0 2px hsl(var(--primary)), 0 2px 8px rgba(0,0,0,0.3);
|
||
`;
|
||
|
||
centerMarkerRef.current = new mapboxgl.Marker({ element: el, anchor: 'center' })
|
||
.setLngLat([center.lng, center.lat])
|
||
.addTo(map);
|
||
|
||
return () => {
|
||
centerMarkerRef.current?.remove();
|
||
centerMarkerRef.current = null;
|
||
};
|
||
}, [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'];
|
||
|
||
const allCategories = Object.entries(POI_CATEGORY_CONFIG) as [
|
||
POICategory,
|
||
(typeof POI_CATEGORY_CONFIG)[POICategory],
|
||
][];
|
||
|
||
return (
|
||
<div className={cn('relative overflow-hidden rounded-lg border', className)}>
|
||
<div ref={mapContainerRef} style={{ height }} className="w-full" />
|
||
|
||
{/* Layer toggle controls */}
|
||
<div className="absolute left-3 top-3 flex flex-col gap-1.5">
|
||
{allCategories.map(([key, config]) => {
|
||
const isActive = activeCategories.has(key);
|
||
const poiCount = pois.filter((p) => p.category === key).length;
|
||
return (
|
||
<button
|
||
key={key}
|
||
type="button"
|
||
onClick={() => toggleCategory(key)}
|
||
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-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}`}
|
||
>
|
||
<config.icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||
<span>{config.label}</span>
|
||
{poiCount > 0 && (
|
||
<span
|
||
className={cn(
|
||
'ml-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-bold',
|
||
isActive
|
||
? 'bg-primary/10 text-primary'
|
||
: 'bg-muted text-muted-foreground',
|
||
)}
|
||
>
|
||
{poiCount}
|
||
</span>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Fallback when no Mapbox token */}
|
||
{!hasToken && (
|
||
<div
|
||
className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-blue-50 to-green-50"
|
||
style={{ height }}
|
||
>
|
||
<div className="text-center">
|
||
<svg
|
||
className="mx-auto mb-2 h-10 w-10 text-muted-foreground"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={1.5}
|
||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||
/>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={1.5}
|
||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||
/>
|
||
</svg>
|
||
<p className="text-sm text-muted-foreground">
|
||
Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để hiển thị bản đồ POI
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|