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:
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 }));
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user