feat: Implement real-time unread support chat count in the sidebar using WebSockets.
This commit is contained in:
@@ -2,6 +2,9 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supportChatService } from '@/services/support-chat.service';
|
||||
import { adminSocketService } from '@/services/socket.service';
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
@@ -106,6 +109,41 @@ const menuItems = [
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
const fetchUnreadCount = useCallback(async () => {
|
||||
try {
|
||||
const count = await supportChatService.getUnreadTotal();
|
||||
setUnreadCount(count);
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial fetch
|
||||
fetchUnreadCount();
|
||||
|
||||
// Connect WebSocket for real-time updates
|
||||
adminSocketService.connect();
|
||||
|
||||
const handleUnreadUpdate = (data: unknown) => {
|
||||
const payload = data as { unreadTotal: number };
|
||||
if (typeof payload?.unreadTotal === 'number') {
|
||||
setUnreadCount(payload.unreadTotal);
|
||||
}
|
||||
};
|
||||
|
||||
adminSocketService.on('support_unread_update', handleUnreadUpdate);
|
||||
|
||||
// Fallback polling every 30s (in case socket disconnects)
|
||||
const interval = setInterval(fetchUnreadCount, 30000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
adminSocketService.off('support_unread_update', handleUnreadUpdate);
|
||||
};
|
||||
}, [fetchUnreadCount]);
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-[#00293d] min-h-screen fixed left-0 top-0">
|
||||
@@ -140,6 +178,7 @@ export default function Sidebar() {
|
||||
{menuItems.map((item) => {
|
||||
const isActive = pathname === item.href ||
|
||||
(item.href !== '/dashboard' && pathname.startsWith(item.href));
|
||||
const showBadge = item.name === 'Support Chat' && unreadCount > 0;
|
||||
|
||||
return (
|
||||
<li key={item.name}>
|
||||
@@ -153,6 +192,11 @@ export default function Sidebar() {
|
||||
>
|
||||
{item.icon}
|
||||
<span className="ml-3 font-medium font-serif text-sm">{item.name}</span>
|
||||
{showBadge && (
|
||||
<span className="ml-auto inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-bold text-white bg-red-500 rounded-full">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
|
||||
88
src/services/socket.service.ts
Normal file
88
src/services/socket.service.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
|
||||
class AdminSocketService {
|
||||
private socket: Socket | null = null;
|
||||
private listeners = new Map<string, Set<(data: unknown) => void>>();
|
||||
|
||||
connect() {
|
||||
if (this.socket?.connected) return;
|
||||
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (!token) return;
|
||||
|
||||
// Strip /api/v1 from API URL to get the socket server URL
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
|
||||
const socketUrl = apiUrl.replace(/\/api\/v1\/?$/, '');
|
||||
|
||||
this.socket = io(socketUrl, {
|
||||
auth: { token },
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnection: true,
|
||||
reconnectionDelay: 2000,
|
||||
reconnectionAttempts: 10,
|
||||
});
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('[AdminSocket] Connected');
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
console.log('[AdminSocket] Disconnected:', reason);
|
||||
});
|
||||
|
||||
this.socket.on('connect_error', (err) => {
|
||||
console.error('[AdminSocket] Connection error:', err.message);
|
||||
});
|
||||
|
||||
// Re-attach all registered listeners
|
||||
this.listeners.forEach((callbacks, event) => {
|
||||
callbacks.forEach((cb) => {
|
||||
this.socket?.on(event, cb as (...args: unknown[]) => void);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
}
|
||||
}
|
||||
|
||||
on(event: string, callback: (data: unknown) => void) {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, new Set());
|
||||
}
|
||||
this.listeners.get(event)!.add(callback);
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.on(event, callback as (...args: unknown[]) => void);
|
||||
}
|
||||
}
|
||||
|
||||
off(event: string, callback: (data: unknown) => void) {
|
||||
const callbacks = this.listeners.get(event);
|
||||
if (callbacks) {
|
||||
callbacks.delete(callback);
|
||||
if (callbacks.size === 0) {
|
||||
this.listeners.delete(event);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.off(event, callback as (...args: unknown[]) => void);
|
||||
}
|
||||
}
|
||||
|
||||
emit(event: string, data: unknown) {
|
||||
if (this.socket?.connected) {
|
||||
this.socket.emit(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.socket?.connected ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
export const adminSocketService = new AdminSocketService();
|
||||
@@ -58,6 +58,11 @@ class SupportChatService {
|
||||
const response = await api.patch<ApiResponse<SupportChat>>(`/support-chat/admin/${chatId}/close`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async getUnreadTotal(): Promise<number> {
|
||||
const response = await api.get<ApiResponse<number>>('/support-chat/admin/unread-total');
|
||||
return response.data.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const supportChatService = new SupportChatService();
|
||||
|
||||
Reference in New Issue
Block a user