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>
75 lines
2.2 KiB
TypeScript
75 lines
2.2 KiB
TypeScript
'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]);
|
|
}
|