feat: add real-time notification system with Socket.IO client
Implements the frontend notification client for TEC-2217: 1. notifications-api.ts — API client for list, unread-count, markAsRead, markAllAsRead endpoints 2. notifications-store.ts — Zustand store for notification state (recent list, unread count, dropdown open state) 3. use-socket-notifications.ts — Socket.IO hook that connects with httpOnly cookie auth, listens for notification:new events, auto-reconnects, and syncs unread count on (re)connect 4. notification-bell.tsx — Bell icon with unread badge + dropdown showing 10 most recent notifications with time-ago formatting, mark-as-read on click, mark-all-as-read, and "Xem tất cả" link 5. notifications-provider.tsx — Provider wired into locale layout (inside AuthProvider) to initialize Socket.IO connection 6. Dashboard header — NotificationBell placed before LanguageSwitcher Added socket.io-client dependency. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { NotificationBell } from '@/components/notifications/notification-bell';
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
||||
@@ -247,6 +248,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
{user.fullName}
|
||||
</span>
|
||||
)}
|
||||
<NotificationBell />
|
||||
<LanguageSwitcher />
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { notFound } from 'next/navigation';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages, getTranslations } from 'next-intl/server';
|
||||
import { AuthProvider } from '@/components/providers/auth-provider';
|
||||
import { NotificationsProvider } from '@/components/providers/notifications-provider';
|
||||
import { QueryProvider } from '@/components/providers/query-provider';
|
||||
import { ThemeProvider } from '@/components/providers/theme-provider';
|
||||
import { WebVitals } from '@/components/providers/web-vitals';
|
||||
@@ -122,8 +123,10 @@ export default async function LocaleLayout({
|
||||
<ThemeProvider>
|
||||
<QueryProvider>
|
||||
<AuthProvider>
|
||||
<WebVitals />
|
||||
{children}
|
||||
<NotificationsProvider>
|
||||
<WebVitals />
|
||||
{children}
|
||||
</NotificationsProvider>
|
||||
</AuthProvider>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
188
apps/web/components/notifications/notification-bell.tsx
Normal file
188
apps/web/components/notifications/notification-bell.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { Bell } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { NotificationDto } from '@/lib/notifications-api';
|
||||
import { useNotificationsStore } from '@/lib/notifications-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function NotificationBell() {
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
isOpen,
|
||||
isLoading,
|
||||
setOpen,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
fetchUnreadCount,
|
||||
} = useNotificationsStore();
|
||||
const router = useRouter();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch unread count on mount
|
||||
useEffect(() => {
|
||||
fetchUnreadCount();
|
||||
}, [fetchUnreadCount]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [isOpen, setOpen]);
|
||||
|
||||
const handleNotificationClick = async (notification: NotificationDto) => {
|
||||
if (!notification.isRead) {
|
||||
await markAsRead(notification.id);
|
||||
}
|
||||
if (notification.link) {
|
||||
router.push(notification.link);
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative h-9 w-9 p-0"
|
||||
aria-label={`Thông báo${unreadCount > 0 ? ` (${unreadCount} chưa đọc)` : ''}`}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
onClick={() => setOpen(!isOpen)}
|
||||
>
|
||||
<Bell className="h-4 w-4" aria-hidden="true" />
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full z-50 mt-2 w-80 overflow-hidden rounded-lg border bg-popover shadow-lg"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 className="text-sm font-semibold">Thông báo</h3>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={markAllAsRead}
|
||||
>
|
||||
Đánh dấu tất cả đã đọc
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-muted-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
Chưa có thông báo
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<button
|
||||
key={notification.id}
|
||||
role="menuitem"
|
||||
className={cn(
|
||||
'flex w-full flex-col gap-0.5 border-b px-4 py-3 text-left transition-colors hover:bg-accent last:border-b-0',
|
||||
!notification.isRead && 'bg-primary/5',
|
||||
)}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{!notification.isRead && (
|
||||
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{notification.title}
|
||||
</p>
|
||||
<p className="line-clamp-2 text-xs text-muted-foreground">
|
||||
{notification.body}
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
{formatTimeAgo(notification.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{notifications.length > 0 && (
|
||||
<div className="border-t px-4 py-2 text-center">
|
||||
<button
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
onClick={() => {
|
||||
router.push('/notifications');
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Xem tất cả
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTimeAgo(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const date = new Date(dateStr).getTime();
|
||||
const diffMs = now - date;
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMin < 1) return 'Vừa xong';
|
||||
if (diffMin < 60) return `${diffMin} phút trước`;
|
||||
const diffHours = Math.floor(diffMin / 60);
|
||||
if (diffHours < 24) return `${diffHours} giờ trước`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 7) return `${diffDays} ngày trước`;
|
||||
return new Date(dateStr).toLocaleDateString('vi-VN');
|
||||
}
|
||||
16
apps/web/components/providers/notifications-provider.tsx
Normal file
16
apps/web/components/providers/notifications-provider.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useSocketNotifications } from '@/lib/hooks/use-socket-notifications';
|
||||
|
||||
/**
|
||||
* Provider component that initializes the Socket.IO connection
|
||||
* for real-time notifications. Must be rendered inside AuthProvider.
|
||||
*/
|
||||
export function NotificationsProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
useSocketNotifications();
|
||||
return <>{children}</>;
|
||||
}
|
||||
74
apps/web/lib/hooks/use-socket-notifications.ts
Normal file
74
apps/web/lib/hooks/use-socket-notifications.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import type { NotificationDto } from '@/lib/notifications-api';
|
||||
import { useNotificationsStore } from '@/lib/notifications-store';
|
||||
|
||||
const SOCKET_URL = process.env['NEXT_PUBLIC_API_URL']?.replace('/api/v1', '') || 'http://localhost:3001';
|
||||
|
||||
/**
|
||||
* Hook that manages the Socket.IO connection for real-time notifications.
|
||||
*
|
||||
* - Connects when user is authenticated
|
||||
* - Listens for `notification:new` events
|
||||
* - Auto-reconnects on disconnect
|
||||
* - Disconnects on logout
|
||||
*/
|
||||
export function useSocketNotifications() {
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const { addNotification, incrementUnread, fetchUnreadCount } =
|
||||
useNotificationsStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
// Disconnect if user logs out
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't create duplicate connections
|
||||
if (socketRef.current?.connected) return;
|
||||
|
||||
const socket = io(SOCKET_URL, {
|
||||
path: '/socket.io',
|
||||
withCredentials: true, // Send httpOnly auth cookies
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 10000,
|
||||
autoConnect: true,
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
// Fetch unread count on (re)connect to sync state
|
||||
fetchUnreadCount();
|
||||
});
|
||||
|
||||
socket.on('notification:new', (data: NotificationDto) => {
|
||||
addNotification(data);
|
||||
incrementUnread();
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
// Socket.IO auto-reconnects for transport errors.
|
||||
// Only manual disconnects ('io client disconnect') need explicit reconnect.
|
||||
if (reason === 'io server disconnect') {
|
||||
socket.connect();
|
||||
}
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [isAuthenticated, addNotification, incrementUnread, fetchUnreadCount]);
|
||||
}
|
||||
52
apps/web/lib/notifications-api.ts
Normal file
52
apps/web/lib/notifications-api.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { apiClient } from './api-client';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────
|
||||
|
||||
export interface NotificationDto {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
body: string;
|
||||
link: string | null;
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PaginatedNotifications {
|
||||
data: NotificationDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface UnreadCountDto {
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ─── API Functions ──────────────────────────────────────
|
||||
|
||||
export const notificationsApi = {
|
||||
/** Get paginated notifications for the current user */
|
||||
list: (params: { page?: number; limit?: number } = {}) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params.page) query.append('page', String(params.page));
|
||||
if (params.limit) query.append('limit', String(params.limit));
|
||||
const qs = query.toString();
|
||||
return apiClient.get<PaginatedNotifications>(
|
||||
`/notifications${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
|
||||
/** Get unread notification count */
|
||||
unreadCount: () =>
|
||||
apiClient.get<UnreadCountDto>('/notifications/unread-count'),
|
||||
|
||||
/** Mark a single notification as read */
|
||||
markAsRead: (id: string) =>
|
||||
apiClient.patch<{ success: boolean }>(`/notifications/${id}/read`),
|
||||
|
||||
/** Mark all notifications as read */
|
||||
markAllAsRead: () =>
|
||||
apiClient.patch<{ success: boolean }>('/notifications/read-all'),
|
||||
};
|
||||
95
apps/web/lib/notifications-store.ts
Normal file
95
apps/web/lib/notifications-store.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { create } from 'zustand';
|
||||
import { notificationsApi, type NotificationDto } from './notifications-api';
|
||||
|
||||
interface NotificationsState {
|
||||
/** Most recent notifications (for dropdown) */
|
||||
notifications: NotificationDto[];
|
||||
/** Unread count for badge */
|
||||
unreadCount: number;
|
||||
/** Whether the dropdown is open */
|
||||
isOpen: boolean;
|
||||
/** Loading state */
|
||||
isLoading: boolean;
|
||||
|
||||
// Actions
|
||||
setOpen: (open: boolean) => void;
|
||||
fetchUnreadCount: () => Promise<void>;
|
||||
fetchRecent: () => Promise<void>;
|
||||
markAsRead: (id: string) => Promise<void>;
|
||||
markAllAsRead: () => Promise<void>;
|
||||
addNotification: (notification: NotificationDto) => void;
|
||||
incrementUnread: () => void;
|
||||
}
|
||||
|
||||
export const useNotificationsStore = create<NotificationsState>((set, get) => ({
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
isOpen: false,
|
||||
isLoading: false,
|
||||
|
||||
setOpen: (open) => {
|
||||
set({ isOpen: open });
|
||||
// Fetch latest when opening dropdown
|
||||
if (open) {
|
||||
get().fetchRecent();
|
||||
}
|
||||
},
|
||||
|
||||
fetchUnreadCount: async () => {
|
||||
try {
|
||||
const { count } = await notificationsApi.unreadCount();
|
||||
set({ unreadCount: count });
|
||||
} catch {
|
||||
// Silently fail — badge just stays at current count
|
||||
}
|
||||
},
|
||||
|
||||
fetchRecent: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const result = await notificationsApi.list({ limit: 10 });
|
||||
set({
|
||||
notifications: result.data,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
markAsRead: async (id) => {
|
||||
try {
|
||||
await notificationsApi.markAsRead(id);
|
||||
set((state) => ({
|
||||
notifications: state.notifications.map((n) =>
|
||||
n.id === id ? { ...n, isRead: true } : n,
|
||||
),
|
||||
unreadCount: Math.max(0, state.unreadCount - 1),
|
||||
}));
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
},
|
||||
|
||||
markAllAsRead: async () => {
|
||||
try {
|
||||
await notificationsApi.markAllAsRead();
|
||||
set((state) => ({
|
||||
notifications: state.notifications.map((n) => ({ ...n, isRead: true })),
|
||||
unreadCount: 0,
|
||||
}));
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
},
|
||||
|
||||
addNotification: (notification) => {
|
||||
set((state) => ({
|
||||
notifications: [notification, ...state.notifications].slice(0, 10),
|
||||
}));
|
||||
},
|
||||
|
||||
incrementUnread: () => {
|
||||
set((state) => ({ unreadCount: state.unreadCount + 1 }));
|
||||
},
|
||||
}));
|
||||
@@ -14,8 +14,11 @@
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@sentry/nextjs": "^10.47.0",
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"mapbox-gl": "^3.21.0",
|
||||
"next": "^15.5.14",
|
||||
@@ -24,6 +27,7 @@
|
||||
"react-dom": "^18.3.0",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"recharts": "^3.8.1",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"web-vitals": "^5.2.0",
|
||||
"zod": "^4.3.6",
|
||||
|
||||
Reference in New Issue
Block a user