/ frontend / src / components / delta / MissedVoteTracker.tsx
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 &rarr;
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  }