Files
goodgo-platform/apps/web/components/neighborhood/neighborhood-poi-map.tsx
Ho Ngoc Hai 603ef7db86 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>
2026-04-21 04:49:35 +07:00

396 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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, // 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'];
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>
);
}