MissedVoteTracker.tsx
1 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' 2 import { useAuthStore } from '../../store/auth' 3 import * as chainService from '../../services/chain' 4 import type { Governor } from '../../types/vote' 5 6 const MAX_MISSED_VOTES = 3 7 8 interface MissedVoteTrackerProps { 9 governor?: Governor 10 className?: string 11 } 12 13 export default function MissedVoteTracker({ 14 governor, 15 className = '', 16 }: MissedVoteTrackerProps) { 17 const { address, chain } = useAuthStore() 18 const queryClient = useQueryClient() 19 20 // If no governor passed, fetch for current user 21 const { data: userGovernor } = useQuery({ 22 queryKey: ['governor', address, 'delta'], 23 queryFn: async () => { 24 const governors = await chainService.getGovernors('delta') 25 return governors.find((g) => g.address === address) || null 26 }, 27 enabled: !governor && !!address && chain === 'delta', 28 }) 29 30 const activeGovernor = governor || userGovernor 31 32 const deregisterMutation = useMutation({ 33 mutationFn: () => chainService.deregisterCodeGovernor(), 34 onSuccess: () => { 35 queryClient.invalidateQueries({ queryKey: ['governors'] }) 36 queryClient.invalidateQueries({ queryKey: ['governor'] }) 37 }, 38 }) 39 40 if (!activeGovernor || activeGovernor.chain !== 'delta') { 41 return null 42 } 43 44 const missedVotes = activeGovernor.missedVotes || 0 45 const votesRemaining = MAX_MISSED_VOTES - missedVotes 46 const isAtRisk = missedVotes >= 2 47 const isDeregistered = activeGovernor.status === 'deregistered' 48 49 // Already deregistered 50 if (isDeregistered) { 51 return ( 52 <div className={`bg-red-50 border border-red-200 rounded-lg p-4 ${className}`}> 53 <div className="flex items-start gap-3"> 54 <XCircleIcon className="h-5 w-5 text-red-600 mt-0.5" /> 55 <div> 56 <h3 className="font-semibold text-red-800">Governor Status: Deregistered</h3> 57 <p className="text-sm text-red-700 mt-1"> 58 This governor was automatically deregistered after missing 3 consecutive 59 votes. To participate again, you must re-register with a valid DX stake. 60 </p> 61 </div> 62 </div> 63 </div> 64 ) 65 } 66 67 // No missed votes - healthy status 68 if (missedVotes === 0) { 69 return ( 70 <div className={`bg-green-50 border border-green-200 rounded-lg p-4 ${className}`}> 71 <div className="flex items-center gap-2"> 72 <CheckCircleIcon className="h-5 w-5 text-green-600" /> 73 <span className="font-medium text-green-800">Good Standing</span> 74 </div> 75 <p className="text-sm text-green-700 mt-1"> 76 No missed votes. Keep participating to maintain your governor status. 77 </p> 78 </div> 79 ) 80 } 81 82 return ( 83 <div 84 className={`rounded-lg p-4 ${ 85 isAtRisk 86 ? 'bg-red-50 border border-red-200' 87 : 'bg-yellow-50 border border-yellow-200' 88 } ${className}`} 89 > 90 <div className="flex items-start gap-3"> 91 <WarningIcon 92 className={`h-5 w-5 mt-0.5 ${ 93 isAtRisk ? 'text-red-600' : 'text-yellow-600' 94 }`} 95 /> 96 <div className="flex-1"> 97 <h3 98 className={`font-semibold ${ 99 isAtRisk ? 'text-red-800' : 'text-yellow-800' 100 }`} 101 > 102 {isAtRisk ? 'Deregistration Warning' : 'Missed Vote Notice'} 103 </h3> 104 105 <p 106 className={`text-sm mt-1 ${ 107 isAtRisk ? 'text-red-700' : 'text-yellow-700' 108 }`} 109 > 110 {isAtRisk ? ( 111 <> 112 <strong>Critical:</strong> You have missed {missedVotes} vote 113 {missedVotes !== 1 ? 's' : ''}. Missing{' '} 114 <strong>{votesRemaining} more</strong> will result in automatic 115 deregistration. 116 </> 117 ) : ( 118 <> 119 You have missed {missedVotes} vote{missedVotes !== 1 ? 's' : ''}. 120 Missing {votesRemaining} more will result in automatic deregistration. 121 </> 122 )} 123 </p> 124 125 {/* Visual indicator */} 126 <div className="mt-3 flex items-center gap-1"> 127 {[...Array(MAX_MISSED_VOTES)].map((_, i) => ( 128 <div 129 key={i} 130 className={`h-3 w-8 rounded ${ 131 i < missedVotes 132 ? isAtRisk 133 ? 'bg-red-500' 134 : 'bg-yellow-500' 135 : 'bg-gray-200' 136 }`} 137 /> 138 ))} 139 <span className="ml-2 text-xs text-gray-500"> 140 {missedVotes}/{MAX_MISSED_VOTES} missed 141 </span> 142 </div> 143 144 {/* Actions */} 145 <div className="mt-4 flex items-center gap-3"> 146 <a 147 href="/votes?status=voting&chain=delta" 148 className={`text-sm font-medium ${ 149 isAtRisk 150 ? 'text-red-700 hover:text-red-800' 151 : 'text-yellow-700 hover:text-yellow-800' 152 }`} 153 > 154 View Active Votes → 155 </a> 156 157 {/* Voluntary deregistration option */} 158 <button 159 onClick={() => { 160 if ( 161 confirm( 162 'Are you sure you want to voluntarily deregister? You can re-register later if you meet the stake requirements.' 163 ) 164 ) { 165 deregisterMutation.mutate() 166 } 167 }} 168 className="text-sm text-gray-500 hover:text-gray-700" 169 > 170 Voluntary Deregister 171 </button> 172 </div> 173 </div> 174 </div> 175 </div> 176 ) 177 } 178 179 /** 180 * Compact badge version 181 */ 182 export function MissedVoteBadge({ missedVotes }: { missedVotes: number }) { 183 if (missedVotes === 0) { 184 return null 185 } 186 187 const isAtRisk = missedVotes >= 2 188 189 return ( 190 <span 191 className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${ 192 isAtRisk ? 'bg-red-100 text-red-700' : 'bg-yellow-100 text-yellow-700' 193 }`} 194 title={`${missedVotes}/${MAX_MISSED_VOTES} votes missed`} 195 > 196 <WarningIcon className="h-3 w-3" /> 197 {missedVotes}/{MAX_MISSED_VOTES} 198 </span> 199 ) 200 } 201 202 function CheckCircleIcon({ className }: { className?: string }) { 203 return ( 204 <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor"> 205 <path 206 strokeLinecap="round" 207 strokeLinejoin="round" 208 strokeWidth={2} 209 d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" 210 /> 211 </svg> 212 ) 213 } 214 215 function XCircleIcon({ className }: { className?: string }) { 216 return ( 217 <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor"> 218 <path 219 strokeLinecap="round" 220 strokeLinejoin="round" 221 strokeWidth={2} 222 d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" 223 /> 224 </svg> 225 ) 226 } 227 228 function WarningIcon({ className }: { className?: string }) { 229 return ( 230 <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor"> 231 <path 232 strokeLinecap="round" 233 strokeLinejoin="round" 234 strokeWidth={2} 235 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" 236 /> 237 </svg> 238 ) 239 }