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:
Ho Ngoc Hai
2026-04-16 02:24:21 +07:00
parent 3a5d2ca9c1
commit 4400d0c123
9 changed files with 851 additions and 13 deletions

View 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]);
}

View 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'),
};

View 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 }));
},
}));