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>
96 lines
2.3 KiB
TypeScript
96 lines
2.3 KiB
TypeScript
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 }));
|
|
},
|
|
}));
|