NotificationBell.tsx
1 import { useState, useEffect, useRef } from 'react' 2 import { useQuery, useQueryClient } from '@tanstack/react-query' 3 import { useAuthStore } from '../../store/auth' 4 import type { Notification } from '../../types/notification' 5 import * as notificationService from '../../services/notification' 6 import NotificationCenter from './NotificationCenter' 7 8 export default function NotificationBell() { 9 const { address, isConnected } = useAuthStore() 10 const queryClient = useQueryClient() 11 const [isOpen, setIsOpen] = useState(false) 12 const bellRef = useRef<HTMLDivElement>(null) 13 14 // Get notification summary 15 const { data: summary } = useQuery({ 16 queryKey: ['notificationSummary', address], 17 queryFn: () => notificationService.getNotificationSummary(address!), 18 enabled: !!address, 19 refetchInterval: 30000, // Refresh every 30 seconds 20 }) 21 22 // WebSocket for real-time notifications 23 useEffect(() => { 24 if (!address) return 25 26 const ws = notificationService.createNotificationSocket( 27 address, 28 (notification: Notification) => { 29 // Invalidate queries to refresh data 30 queryClient.invalidateQueries({ queryKey: ['notificationSummary', address] }) 31 queryClient.invalidateQueries({ queryKey: ['notifications', address] }) 32 33 // Show browser notification if permitted 34 if (Notification.permission === 'granted') { 35 new Notification(notification.title, { 36 body: notification.message, 37 icon: '/favicon.ico', 38 }) 39 } 40 } 41 ) 42 43 return () => { 44 ws?.close() 45 } 46 }, [address, queryClient]) 47 48 // Close on click outside 49 useEffect(() => { 50 function handleClickOutside(event: MouseEvent) { 51 if (bellRef.current && !bellRef.current.contains(event.target as Node)) { 52 setIsOpen(false) 53 } 54 } 55 56 document.addEventListener('mousedown', handleClickOutside) 57 return () => document.removeEventListener('mousedown', handleClickOutside) 58 }, []) 59 60 if (!isConnected) return null 61 62 const unreadCount = summary?.unreadCount || 0 63 const hasUrgent = (summary?.urgentCount || 0) > 0 64 65 return ( 66 <div ref={bellRef} className="relative"> 67 <button 68 onClick={() => setIsOpen(!isOpen)} 69 className="relative p-2 rounded-lg hover:bg-gray-100 transition-colors" 70 aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ''}`} 71 > 72 <BellIcon className="h-5 w-5 text-gray-600" /> 73 74 {/* Unread badge */} 75 {unreadCount > 0 && ( 76 <span 77 className={`absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] flex items-center justify-center text-xs font-medium rounded-full ${ 78 hasUrgent ? 'bg-red-500 text-white' : 'bg-blue-500 text-white' 79 }`} 80 > 81 {unreadCount > 99 ? '99+' : unreadCount} 82 </span> 83 )} 84 </button> 85 86 {/* Dropdown */} 87 {isOpen && ( 88 <div className="absolute right-0 mt-2 w-96 max-h-[80vh] bg-white rounded-lg shadow-lg border overflow-hidden z-50"> 89 <NotificationCenter onClose={() => setIsOpen(false)} /> 90 </div> 91 )} 92 </div> 93 ) 94 } 95 96 function BellIcon({ className }: { className?: string }) { 97 return ( 98 <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor"> 99 <path 100 strokeLinecap="round" 101 strokeLinejoin="round" 102 strokeWidth={2} 103 d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" 104 /> 105 </svg> 106 ) 107 }