Files
goodgo-platform/apps/web/lib/notifications-store.ts
Ho Ngoc Hai 4400d0c123 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>
2026-04-16 02:24:21 +07:00

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