/ frontend / src / components / notifications / NotificationCard.tsx
NotificationCard.tsx
  1  import { useMutation, useQueryClient } from '@tanstack/react-query'
  2  import { Link } from 'react-router-dom'
  3  import { useAuthStore } from '../../store/auth'
  4  import type { Notification, NotificationType } from '../../types/notification'
  5  import * as notificationService from '../../services/notification'
  6  
  7  interface NotificationCardProps {
  8    notification: Notification
  9    onClose?: () => void
 10  }
 11  
 12  export default function NotificationCard({
 13    notification,
 14    onClose,
 15  }: NotificationCardProps) {
 16    const { address } = useAuthStore()
 17    const queryClient = useQueryClient()
 18  
 19    const markReadMutation = useMutation({
 20      mutationFn: () => notificationService.markAsRead(notification.id),
 21      onSuccess: () => {
 22        queryClient.invalidateQueries({ queryKey: ['notifications', address] })
 23        queryClient.invalidateQueries({ queryKey: ['notificationSummary', address] })
 24      },
 25    })
 26  
 27    const handleClick = () => {
 28      if (!notification.read) {
 29        markReadMutation.mutate()
 30      }
 31      onClose?.()
 32    }
 33  
 34    const linkUrl = getLinkUrl(notification)
 35    const IconComponent = getNotificationTypeIcon(notification.type)
 36    const priorityColors = getPriorityColors(notification.priority)
 37  
 38    const content = (
 39      <div
 40        className={`p-4 hover:bg-gray-50 transition-colors ${
 41          !notification.read ? 'bg-blue-50/50' : ''
 42        }`}
 43      >
 44        <div className="flex gap-3">
 45          {/* Icon */}
 46          <div
 47            className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${priorityColors.bg}`}
 48          >
 49            <IconComponent className={`h-4 w-4 ${priorityColors.icon}`} />
 50          </div>
 51  
 52          {/* Content */}
 53          <div className="flex-1 min-w-0">
 54            <div className="flex items-start justify-between gap-2">
 55              <p className={`text-sm ${!notification.read ? 'font-medium' : ''}`}>
 56                {notification.title}
 57              </p>
 58              <span className="text-xs text-gray-400 whitespace-nowrap">
 59                {notificationService.formatNotificationTime(notification.createdAt)}
 60              </span>
 61            </div>
 62            <p className="text-sm text-gray-600 mt-0.5 line-clamp-2">
 63              {notification.message}
 64            </p>
 65            {/* Chain badge */}
 66            <span
 67              className={`inline-block mt-1.5 text-xs px-1.5 py-0.5 rounded ${
 68                notification.chain === 'alpha'
 69                  ? 'bg-purple-100 text-purple-700'
 70                  : 'bg-green-100 text-green-700'
 71              }`}
 72            >
 73              {notification.chain}
 74            </span>
 75          </div>
 76  
 77          {/* Unread indicator */}
 78          {!notification.read && (
 79            <div className="flex-shrink-0">
 80              <div className="w-2 h-2 rounded-full bg-blue-500" />
 81            </div>
 82          )}
 83        </div>
 84      </div>
 85    )
 86  
 87    if (linkUrl) {
 88      return (
 89        <Link to={linkUrl} onClick={handleClick}>
 90          {content}
 91        </Link>
 92      )
 93    }
 94  
 95    return (
 96      <div onClick={handleClick} className="cursor-pointer">
 97        {content}
 98      </div>
 99    )
100  }
101  
102  function getLinkUrl(notification: Notification): string | null {
103    if (!notification.link) return null
104  
105    switch (notification.link.type) {
106      case 'pr':
107      case 'vote':
108        return `/votes/${notification.link.id}`
109      case 'repo':
110        return `/repos/${notification.link.id}`
111      case 'governor':
112        return `/governors`
113      case 'emergency':
114        return `/emergency`
115      case 'external':
116        return notification.link.url || null
117      default:
118        return null
119    }
120  }
121  
122  function getPriorityColors(priority: string): { bg: string; icon: string } {
123    const colors: Record<string, { bg: string; icon: string }> = {
124      low: { bg: 'bg-gray-100', icon: 'text-gray-500' },
125      medium: { bg: 'bg-blue-100', icon: 'text-blue-600' },
126      high: { bg: 'bg-orange-100', icon: 'text-orange-600' },
127      urgent: { bg: 'bg-red-100', icon: 'text-red-600' },
128    }
129    return colors[priority] || colors.low
130  }
131  
132  function getNotificationTypeIcon(
133    type: NotificationType
134  ): React.ComponentType<{ className?: string }> {
135    switch (type) {
136      case 'vote_started':
137      case 'vote_ending':
138        return VoteIcon
139      case 'vote_passed':
140      case 'pr_merged':
141        return CheckIcon
142      case 'vote_failed':
143        return XIcon
144      case 'pr_sponsored':
145        return StarIcon
146      case 'comment_reply':
147      case 'mention':
148        return MessageIcon
149      case 'emergency_action':
150      case 'stake_warning':
151        return AlertIcon
152      case 'governor_registered':
153        return UserPlusIcon
154      case 'missed_vote':
155        return ClockIcon
156      default:
157        return BellIcon
158    }
159  }
160  
161  // Icons
162  function VoteIcon({ className }: { className?: string }) {
163    return (
164      <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
165        <path
166          strokeLinecap="round"
167          strokeLinejoin="round"
168          strokeWidth={2}
169          d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
170        />
171      </svg>
172    )
173  }
174  
175  function CheckIcon({ className }: { className?: string }) {
176    return (
177      <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
178        <path
179          strokeLinecap="round"
180          strokeLinejoin="round"
181          strokeWidth={2}
182          d="M5 13l4 4L19 7"
183        />
184      </svg>
185    )
186  }
187  
188  function XIcon({ className }: { className?: string }) {
189    return (
190      <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
191        <path
192          strokeLinecap="round"
193          strokeLinejoin="round"
194          strokeWidth={2}
195          d="M6 18L18 6M6 6l12 12"
196        />
197      </svg>
198    )
199  }
200  
201  function StarIcon({ className }: { className?: string }) {
202    return (
203      <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
204        <path
205          strokeLinecap="round"
206          strokeLinejoin="round"
207          strokeWidth={2}
208          d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
209        />
210      </svg>
211    )
212  }
213  
214  function MessageIcon({ className }: { className?: string }) {
215    return (
216      <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
217        <path
218          strokeLinecap="round"
219          strokeLinejoin="round"
220          strokeWidth={2}
221          d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
222        />
223      </svg>
224    )
225  }
226  
227  function AlertIcon({ className }: { className?: string }) {
228    return (
229      <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
230        <path
231          strokeLinecap="round"
232          strokeLinejoin="round"
233          strokeWidth={2}
234          d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
235        />
236      </svg>
237    )
238  }
239  
240  function UserPlusIcon({ className }: { className?: string }) {
241    return (
242      <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
243        <path
244          strokeLinecap="round"
245          strokeLinejoin="round"
246          strokeWidth={2}
247          d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
248        />
249      </svg>
250    )
251  }
252  
253  function ClockIcon({ className }: { className?: string }) {
254    return (
255      <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
256        <path
257          strokeLinecap="round"
258          strokeLinejoin="round"
259          strokeWidth={2}
260          d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
261        />
262      </svg>
263    )
264  }
265  
266  function BellIcon({ className }: { className?: string }) {
267    return (
268      <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
269        <path
270          strokeLinecap="round"
271          strokeLinejoin="round"
272          strokeWidth={2}
273          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"
274        />
275      </svg>
276    )
277  }