/ frontend / src / pages / NotificationsPage.tsx
NotificationsPage.tsx
  1  import { useState } from 'react'
  2  import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
  3  import { useAuthStore } from '../store/auth'
  4  import type { NotificationType } from '../types/notification'
  5  import * as notificationService from '../services/notification'
  6  import NotificationCard from '../components/notifications/NotificationCard'
  7  
  8  const NOTIFICATION_FILTERS: { value: NotificationType | 'all'; label: string }[] = [
  9    { value: 'all', label: 'All' },
 10    { value: 'vote_started', label: 'Votes' },
 11    { value: 'pr_sponsored', label: 'PRs' },
 12    { value: 'comment_reply', label: 'Comments' },
 13    { value: 'emergency_action', label: 'Emergency' },
 14  ]
 15  
 16  export default function NotificationsPage() {
 17    const { address, isConnected } = useAuthStore()
 18    const queryClient = useQueryClient()
 19    const [filter, setFilter] = useState<NotificationType | 'all'>('all')
 20    const [showUnreadOnly, setShowUnreadOnly] = useState(false)
 21  
 22    // Fetch notifications
 23    const { data: notifications = [], isLoading } = useQuery({
 24      queryKey: ['notifications', address, filter, showUnreadOnly],
 25      queryFn: () =>
 26        notificationService.getNotifications(address!, {
 27          unreadOnly: showUnreadOnly,
 28          types: filter === 'all' ? undefined : [filter],
 29          limit: 100,
 30        }),
 31      enabled: !!address,
 32    })
 33  
 34    // Mark all as read
 35    const markAllReadMutation = useMutation({
 36      mutationFn: () => notificationService.markAllAsRead(address!),
 37      onSuccess: () => {
 38        queryClient.invalidateQueries({ queryKey: ['notifications'] })
 39        queryClient.invalidateQueries({ queryKey: ['notificationSummary'] })
 40      },
 41    })
 42  
 43    // Group by date
 44    const groups = notificationService.groupNotificationsByDate(notifications)
 45    const unreadCount = notifications.filter((n) => !n.read).length
 46  
 47    if (!isConnected) {
 48      return (
 49        <div className="text-center py-12">
 50          <p className="text-gray-500">Connect your wallet to view notifications</p>
 51        </div>
 52      )
 53    }
 54  
 55    return (
 56      <div>
 57        {/* Header */}
 58        <div className="flex items-center justify-between mb-6">
 59          <div>
 60            <h1 className="text-2xl font-bold">Notifications</h1>
 61            <p className="text-gray-600 text-sm mt-1">
 62              {unreadCount > 0 ? `${unreadCount} unread` : 'All caught up'}
 63            </p>
 64          </div>
 65  
 66          <div className="flex items-center gap-4">
 67            {unreadCount > 0 && (
 68              <button
 69                onClick={() => markAllReadMutation.mutate()}
 70                disabled={markAllReadMutation.isPending}
 71                className="text-sm text-blue-600 hover:underline"
 72              >
 73                Mark all as read
 74              </button>
 75            )}
 76          </div>
 77        </div>
 78  
 79        {/* Filters */}
 80        <div className="flex items-center gap-4 mb-6">
 81          <div className="flex bg-gray-100 rounded-lg p-1">
 82            {NOTIFICATION_FILTERS.map(({ value, label }) => (
 83              <button
 84                key={value}
 85                onClick={() => setFilter(value)}
 86                className={`px-3 py-1.5 rounded-md text-sm ${
 87                  filter === value
 88                    ? 'bg-white shadow text-gray-900 font-medium'
 89                    : 'text-gray-600 hover:text-gray-900'
 90                }`}
 91              >
 92                {label}
 93              </button>
 94            ))}
 95          </div>
 96  
 97          <label className="flex items-center gap-2 cursor-pointer">
 98            <input
 99              type="checkbox"
100              checked={showUnreadOnly}
101              onChange={(e) => setShowUnreadOnly(e.target.checked)}
102              className="w-4 h-4 rounded border-gray-300"
103            />
104            <span className="text-sm text-gray-600">Unread only</span>
105          </label>
106        </div>
107  
108        {/* Notification List */}
109        {isLoading ? (
110          <div className="flex justify-center py-12">
111            <div className="animate-spin h-8 w-8 border-2 border-gray-400 border-t-transparent rounded-full" />
112          </div>
113        ) : notifications.length === 0 ? (
114          <div className="text-center py-12 bg-white border rounded-lg">
115            <BellOffIcon className="h-12 w-12 mx-auto text-gray-300" />
116            <p className="text-gray-500 mt-3">No notifications</p>
117            {filter !== 'all' && (
118              <button
119                onClick={() => setFilter('all')}
120                className="mt-2 text-sm text-blue-600 hover:underline"
121              >
122                Clear filter
123              </button>
124            )}
125          </div>
126        ) : (
127          <div className="bg-white border rounded-lg divide-y">
128            {groups.map((group) => (
129              <div key={group.date}>
130                <div className="px-4 py-2 bg-gray-50 text-sm font-medium text-gray-600 sticky top-0">
131                  {group.date}
132                </div>
133                <div className="divide-y">
134                  {group.notifications.map((notification) => (
135                    <NotificationCard
136                      key={notification.id}
137                      notification={notification}
138                    />
139                  ))}
140                </div>
141              </div>
142            ))}
143          </div>
144        )}
145      </div>
146    )
147  }
148  
149  function BellOffIcon({ className }: { className?: string }) {
150    return (
151      <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
152        <path
153          strokeLinecap="round"
154          strokeLinejoin="round"
155          strokeWidth={2}
156          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"
157        />
158        <path
159          strokeLinecap="round"
160          strokeLinejoin="round"
161          strokeWidth={2}
162          d="M3 3l18 18"
163        />
164      </svg>
165    )
166  }