/ frontend / src / components / notifications / NotificationBell.tsx
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  }