import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useRef, useState } from 'react' import { Bell, CheckCheck } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { toast } from 'sonner' import { api } from '@/lib/api' import { ensureConnection } from '@/lib/realtime' import { useAuth } from '@/contexts/AuthContext' import { cn } from '@/lib/cn' type NotificationDto = { id: string type: number title: string description: string | null href: string | null refId: string | null createdAt: string readAt: string | null } const fmtWhen = (iso: string) => { const diff = Date.now() - new Date(iso).getTime() const mins = Math.floor(diff / 60_000) if (mins < 1) return 'vừa xong' if (mins < 60) return `${mins}p trước` const hrs = Math.floor(mins / 60) if (hrs < 24) return `${hrs}h trước` const days = Math.floor(hrs / 24) return `${days}d trước` } export function NotificationBell() { const [open, setOpen] = useState(false) const panelRef = useRef(null) const navigate = useNavigate() const qc = useQueryClient() const { isAuthenticated } = useAuth() const list = useQuery({ queryKey: ['notifications'], queryFn: async () => (await api.get('/notifications', { params: { limit: 20 } })).data, // Fallback polling at 60s in case SignalR disconnects + reconnect fails refetchInterval: 60_000, }) // Subscribe realtime when authenticated. Toast on push + invalidate query. useEffect(() => { if (!isAuthenticated) return let conn: Awaited> | null = null let cancelled = false const handler = (payload: NotificationDto) => { qc.invalidateQueries({ queryKey: ['notifications'] }) toast.message(payload.title, { description: payload.description ?? undefined }) } ensureConnection() .then(c => { if (cancelled) return conn = c c.on('notification-created', handler) }) .catch(() => { // SignalR unavailable — rely on polling fallback }) return () => { cancelled = true if (conn) conn.off('notification-created', handler) } }, [isAuthenticated, qc]) const items = list.data ?? [] const unread = items.filter(n => !n.readAt).length const markRead = useMutation({ mutationFn: async (id: string) => api.post(`/notifications/${id}/read`), onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }), }) const markAllRead = useMutation({ mutationFn: async () => api.post('/notifications/read-all'), onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }), }) useEffect(() => { if (!open) return const close = (e: MouseEvent) => { if (panelRef.current && !panelRef.current.contains(e.target as Node)) setOpen(false) } document.addEventListener('mousedown', close) return () => document.removeEventListener('mousedown', close) }, [open]) return (
{open && (
Thông báo
{unread > 0 ? `${unread} chưa đọc` : 'Đã đọc hết'}
{unread > 0 && ( )}
{items.length === 0 && (
Không có thông báo nào.
)} {items.map(n => ( ))}
)}
) }