/ frontend / src / components / notifications / NotificationCenter.tsx
NotificationCenter.tsx
  1  import { useState } from 'react'
  2  import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
  3  import { Link } from 'react-router-dom'
  4  import { useAuthStore } from '../../store/auth'
  5  import type { NotificationGroup } from '../../types/notification'
  6  import * as notificationService from '../../services/notification'
  7  import NotificationCard from './NotificationCard'
  8  
  9  interface NotificationCenterProps {
 10    onClose?: () => void
 11  }
 12  
 13  export default function NotificationCenter({ onClose }: NotificationCenterProps) {
 14    const { address } = useAuthStore()
 15    const queryClient = useQueryClient()
 16    const [filter, setFilter] = useState<'all' | 'unread'>('all')
 17  
 18    // Fetch notifications
 19    const { data: notifications = [], isLoading } = useQuery({
 20      queryKey: ['notifications', address, filter],
 21      queryFn: () =>
 22        notificationService.getNotifications(address!, {
 23          unreadOnly: filter === 'unread',
 24          limit: 50,
 25        }),
 26      enabled: !!address,
 27    })
 28  
 29    // Mark all as read mutation
 30    const markAllReadMutation = useMutation({
 31      mutationFn: () => notificationService.markAllAsRead(address!),
 32      onSuccess: () => {
 33        queryClient.invalidateQueries({ queryKey: ['notifications', address] })
 34        queryClient.invalidateQueries({ queryKey: ['notificationSummary', address] })
 35      },
 36    })
 37  
 38    // Group notifications by date
 39    const groups = notificationService.groupNotificationsByDate(notifications)
 40    const unreadCount = notifications.filter((n) => !n.read).length
 41  
 42    return (
 43      <div className="flex flex-col max-h-[80vh]">
 44        {/* Header */}
 45        <div className="p-4 border-b flex items-center justify-between">
 46          <h3 className="font-semibold">Notifications</h3>
 47          <div className="flex items-center gap-2">
 48            {unreadCount > 0 && (
 49              <button
 50                onClick={() => markAllReadMutation.mutate()}
 51                disabled={markAllReadMutation.isPending}
 52                className="text-xs text-blue-600 hover:underline"
 53              >
 54                Mark all read
 55              </button>
 56            )}
 57            <Link
 58              to="/settings/notifications"
 59              onClick={onClose}
 60              className="text-gray-400 hover:text-gray-600"
 61            >
 62              <SettingsIcon className="h-4 w-4" />
 63            </Link>
 64          </div>
 65        </div>
 66  
 67        {/* Filter tabs */}
 68        <div className="px-4 py-2 border-b bg-gray-50">
 69          <div className="flex gap-4">
 70            <button
 71              onClick={() => setFilter('all')}
 72              className={`text-sm pb-1 border-b-2 ${
 73                filter === 'all'
 74                  ? 'border-blue-500 text-blue-600 font-medium'
 75                  : 'border-transparent text-gray-500 hover:text-gray-700'
 76              }`}
 77            >
 78              All
 79            </button>
 80            <button
 81              onClick={() => setFilter('unread')}
 82              className={`text-sm pb-1 border-b-2 ${
 83                filter === 'unread'
 84                  ? 'border-blue-500 text-blue-600 font-medium'
 85                  : 'border-transparent text-gray-500 hover:text-gray-700'
 86              }`}
 87            >
 88              Unread {unreadCount > 0 && `(${unreadCount})`}
 89            </button>
 90          </div>
 91        </div>
 92  
 93        {/* Notification list */}
 94        <div className="flex-1 overflow-y-auto">
 95          {isLoading ? (
 96            <div className="flex justify-center py-8">
 97              <div className="animate-spin h-6 w-6 border-2 border-gray-400 border-t-transparent rounded-full" />
 98            </div>
 99          ) : notifications.length === 0 ? (
100            <div className="py-12 text-center">
101              <BellOffIcon className="h-12 w-12 mx-auto text-gray-300" />
102              <p className="text-gray-500 mt-2">No notifications</p>
103            </div>
104          ) : (
105            <div className="divide-y">
106              {groups.map((group) => (
107                <NotificationGroupSection
108                  key={group.date}
109                  group={group}
110                  onClose={onClose}
111                />
112              ))}
113            </div>
114          )}
115        </div>
116  
117        {/* Footer */}
118        {notifications.length > 0 && (
119          <div className="p-3 border-t bg-gray-50 text-center">
120            <Link
121              to="/notifications"
122              onClick={onClose}
123              className="text-sm text-blue-600 hover:underline"
124            >
125              View all notifications
126            </Link>
127          </div>
128        )}
129      </div>
130    )
131  }
132  
133  function NotificationGroupSection({
134    group,
135    onClose,
136  }: {
137    group: NotificationGroup
138    onClose?: () => void
139  }) {
140    return (
141      <div>
142        <div className="px-4 py-2 bg-gray-50 text-xs font-medium text-gray-500 sticky top-0">
143          {group.date}
144        </div>
145        <div className="divide-y">
146          {group.notifications.map((notification) => (
147            <NotificationCard
148              key={notification.id}
149              notification={notification}
150              onClose={onClose}
151            />
152          ))}
153        </div>
154      </div>
155    )
156  }
157  
158  function SettingsIcon({ className }: { className?: string }) {
159    return (
160      <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
161        <path
162          strokeLinecap="round"
163          strokeLinejoin="round"
164          strokeWidth={2}
165          d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
166        />
167        <path
168          strokeLinecap="round"
169          strokeLinejoin="round"
170          strokeWidth={2}
171          d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
172        />
173      </svg>
174    )
175  }
176  
177  function BellOffIcon({ className }: { className?: string }) {
178    return (
179      <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
180        <path
181          strokeLinecap="round"
182          strokeLinejoin="round"
183          strokeWidth={2}
184          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"
185        />
186        <path
187          strokeLinecap="round"
188          strokeLinejoin="round"
189          strokeWidth={2}
190          d="M3 3l18 18"
191        />
192      </svg>
193    )
194  }