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 }