diff --git a/src/components/common/MergeProposalBanner.tsx b/src/components/common/MergeProposalBanner.tsx new file mode 100644 index 00000000..e12ffa3a --- /dev/null +++ b/src/components/common/MergeProposalBanner.tsx @@ -0,0 +1,239 @@ +import { useState } from 'react'; +import { + CheckCircle2, + Clock4, + GitMerge, + Loader2, + ThumbsDown, + ThumbsUp, + XCircle, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { useMergeProposal, useCastMergeVote } from '@/hooks/useMergeProposal'; +import MergeProposalCountdown from '@/components/common/MergeProposalCountdown'; +import { + getApprovalPercentage, + getMergeProposalOutcome, + getRequiredThresholdPercentage, + getTotalVotesCast, + isQualifyingVoter, + isVotingOpen, +} from '@/utils/mergeProposal.utils'; +import { formatCompactNumber } from '@/utils/numberFormat.utils'; +import type { MergeVoteDirection } from '@/types/mergeProposal'; + +export interface MergeProposalBannerProps { + sourceKeyId: string; + /** Number of source keys the connected wallet holds. */ + holdingsCount: number; + /** Whether a wallet is connected at all. */ + isConnected: boolean; + className?: string; +} + +const OUTCOME_COPY: Record< + 'passed' | 'failed' | 'pending_execution', + { label: string; icon: React.ElementType; classes: string } +> = { + passed: { + label: 'Merge proposal passed', + icon: CheckCircle2, + classes: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300', + }, + pending_execution: { + label: 'Merge proposal passed — pending execution', + icon: Clock4, + classes: 'border-blue-500/30 bg-blue-500/10 text-blue-300', + }, + failed: { + label: 'Merge proposal failed', + icon: XCircle, + classes: 'border-red-500/30 bg-red-500/10 text-red-300', + }, +}; + +/** + * Merge proposal voting banner shown on a source key's detail page (#983). + * + * - Renders only when the key has a merge proposal at all (active or + * recently closed). + * - Shows approve/reject vote buttons only to qualifying holders while + * voting is open. + * - Shows a live tally (approval % vs. required threshold) that refreshes + * every 30s via React Query. + * - Shows a deadline countdown while voting is open, and an outcome banner + * (passed / failed / pending execution) once it closes. + */ +export default function MergeProposalBanner({ + sourceKeyId, + holdingsCount, + isConnected, + className, +}: MergeProposalBannerProps) { + const { data: proposal, isLoading } = useMergeProposal(sourceKeyId); + const castVote = useCastMergeVote(sourceKeyId); + const [pendingDirection, setPendingDirection] = + useState(null); + + if (isLoading || !proposal) return null; + + const votingOpen = isVotingOpen(proposal); + const outcome = getMergeProposalOutcome(proposal); + const approvalPct = getApprovalPercentage(proposal); + const requiredPct = getRequiredThresholdPercentage( + proposal.approvalThresholdBps + ); + const totalVotes = getTotalVotesCast(proposal); + const canVote = + votingOpen && + isConnected && + isQualifyingVoter(proposal, holdingsCount); + + const handleVote = (direction: MergeVoteDirection) => { + setPendingDirection(direction); + castVote.mutate( + { proposalId: proposal.id, direction }, + { onSettled: () => setPendingDirection(null) } + ); + }; + + return ( +
+
+
+
+ + + {votingOpen && ( + + )} +
+

+ {proposal.title} +

+

+ {proposal.description} +

+
+ + {/* Outcome banner — post-deadline result (#983 AC) */} + {!votingOpen && outcome && ( + + {(() => { + const Icon = OUTCOME_COPY[outcome].icon; + return + )} +
+ + {/* Live vote tally */} +
+
+
= requiredPct ? 'bg-emerald-500' : 'bg-amber-400' + )} + style={{ width: `${approvalPct}%` }} + aria-hidden="true" + /> + +
+ + + {approvalPct.toFixed(1)}% + {' '} + approval · {requiredPct.toFixed(0)}% required + + + + + + + + {formatCompactNumber(totalVotes)} votes cast + + +
+
+ + {/* Vote actions — qualifying holders only, while voting is open */} + {votingOpen && ( +
+ {canVote ? ( + <> + + + + ) : ( +

+ {proposal.userVote + ? `You voted to ${proposal.userVote} this proposal.` + : !isConnected + ? 'Connect a wallet holding this key to vote.' + : `Hold at least ${proposal.minHoldingToVote} key${ + proposal.minHoldingToVote === 1 ? '' : 's' + } to vote on this proposal.`} +

+ )} +
+ )} +
+ ); +} diff --git a/src/components/common/MergeProposalCountdown.tsx b/src/components/common/MergeProposalCountdown.tsx new file mode 100644 index 00000000..8dc7d296 --- /dev/null +++ b/src/components/common/MergeProposalCountdown.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react'; +import { Clock } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { + formatCountdownDuration, + getRemainingMs, +} from '@/utils/mergeProposal.utils'; + +export interface MergeProposalCountdownProps { + votingDeadline: string; + onExpire?: () => void; + className?: string; +} + +/** + * Ticking countdown to a merge proposal's voting deadline (#983). Updates + * every second from the actual deadline timestamp so it stays accurate + * regardless of how long the page has been open. + */ +export default function MergeProposalCountdown({ + votingDeadline, + onExpire, + className, +}: MergeProposalCountdownProps) { + const [remainingMs, setRemainingMs] = useState(() => + getRemainingMs(votingDeadline) + ); + + useEffect(() => { + const initial = getRemainingMs(votingDeadline); + setRemainingMs(initial); + + if (initial <= 0) { + onExpire?.(); + return; + } + + const intervalId = setInterval(() => { + const remaining = getRemainingMs(votingDeadline); + setRemainingMs(remaining); + if (remaining <= 0) { + clearInterval(intervalId); + onExpire?.(); + } + }, 1000); + + return () => clearInterval(intervalId); + }, [votingDeadline, onExpire]); + + if (remainingMs <= 0) return null; + + const formatted = formatCountdownDuration(remainingMs); + + return ( +
+
+ ); +} diff --git a/src/hooks/useMergeProposal.ts b/src/hooks/useMergeProposal.ts new file mode 100644 index 00000000..a3c994b8 --- /dev/null +++ b/src/hooks/useMergeProposal.ts @@ -0,0 +1,98 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { mergeProposalService } from '@/services/mergeProposal.service'; +import { isVotingOpen } from '@/utils/mergeProposal.utils'; +import type { MergeProposal, MergeVoteDirection } from '@/types/mergeProposal'; +import showToast from '@/utils/toast.util'; + +/** How often the tally refetches while a merge vote is active (#983). */ +export const MERGE_PROPOSAL_REFETCH_INTERVAL_MS = 30_000; + +/** + * Fetches the merge proposal (if any) for a source key. Polls every 30s + * via React Query so the live vote tally and countdown stay in sync with + * other holders' votes, without the user needing to refresh. + */ +export function useMergeProposal(sourceKeyId: string) { + return useQuery({ + queryKey: queryKeys.mergeProposal.bySourceKey(sourceKeyId), + queryFn: () => mergeProposalService.getMergeProposal(sourceKeyId), + enabled: Boolean(sourceKeyId), + staleTime: 20_000, + // Only keep polling while a proposal is actually open for voting; + // once it's closed there's nothing left to converge on. + refetchInterval: query => { + const proposal = query.state.data; + if (!proposal) return MERGE_PROPOSAL_REFETCH_INTERVAL_MS; + return isVotingOpen(proposal) + ? MERGE_PROPOSAL_REFETCH_INTERVAL_MS + : false; + }, + }); +} + +function errorMessage(error: unknown): string { + return error instanceof Error + ? error.message + : 'Your vote could not be submitted. Please try again.'; +} + +/** + * Casts an approve/reject vote on a merge proposal and optimistically + * reflects it in the cached tally so the UI feels instant, reconciling + * with the server response (or rolling back on failure). + */ +export function useCastMergeVote(sourceKeyId: string) { + const queryClient = useQueryClient(); + const queryKey = queryKeys.mergeProposal.bySourceKey(sourceKeyId); + + return useMutation({ + mutationKey: ['mergeProposal', 'vote', sourceKeyId], + mutationFn: ({ + proposalId, + direction, + }: { + proposalId: string; + direction: MergeVoteDirection; + }) => mergeProposalService.castVote(proposalId, direction), + onMutate: async ({ direction }) => { + await queryClient.cancelQueries({ queryKey }); + const previous = + queryClient.getQueryData(queryKey); + + if (previous) { + queryClient.setQueryData(queryKey, () => ({ + ...previous, + userVote: direction, + approveWeight: + previous.approveWeight + + (direction === 'approve' ? 1 : 0), + rejectWeight: + previous.rejectWeight + (direction === 'reject' ? 1 : 0), + })); + } + + return { previous }; + }, + onError: (error, _variables, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData(queryKey, context.previous); + } + showToast.error(errorMessage(error)); + }, + onSuccess: result => { + queryClient.setQueryData( + queryKey, + () => result.proposal + ); + showToast.success( + result.direction === 'approve' + ? 'Vote to approve the merge submitted' + : 'Vote to reject the merge submitted' + ); + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey }); + }, + }); +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 5d1719dc..28ed0221 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -93,6 +93,10 @@ export const queryKeys = { proposalVotes: (id: string) => ['governance', 'proposal', id, 'votes'] as const, }, + mergeProposal: { + bySourceKey: (sourceKeyId: string) => + ['mergeProposal', 'source', sourceKeyId] as const, + }, staker: { protocolRevenue: (wallet: string) => ['staker', wallet, 'protocol-revenue'] as const, diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index 8490a850..8e0f5e9d 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -48,6 +48,7 @@ import KeyStatsPanel from '@/components/common/KeyStatsPanel'; import Skeleton from '@/components/ui/skeleton'; import { Tooltip } from '@/components/ui/tooltip'; import KeyDeprecationBanner from '@/components/common/KeyDeprecationBanner'; +import MergeProposalBanner from '@/components/common/MergeProposalBanner'; import KeyBuybackModal from '@/components/common/KeyBuybackModal'; import type { KeyBuybackReceipt } from '@/hooks/useKeyBuyback'; import { usePerformanceBond } from '@/hooks/usePerformanceBond'; @@ -301,6 +302,12 @@ function CreatorDetailPageContent() { recentSettlement={recentSettlement} /> )} + {/* Merge proposal voting for source key holders (#983) */} +
); -} +} \ No newline at end of file diff --git a/src/services/mergeProposal.service.ts b/src/services/mergeProposal.service.ts new file mode 100644 index 00000000..3d508d15 --- /dev/null +++ b/src/services/mergeProposal.service.ts @@ -0,0 +1,46 @@ +// src/services/mergeProposal.service.ts +import { BaseApiService, type APIResponse } from './api.service'; +import type { + MergeProposal, + MergeVoteCastResult, + MergeVoteDirection, +} from '@/types/mergeProposal'; + +class MergeProposalService extends BaseApiService { + /** + * Fetch the active (or most recently closed) merge proposal for a + * source key, if one exists. + * GET /keys/:sourceKeyId/merge-proposal + */ + async getMergeProposal(sourceKeyId: string): Promise { + try { + const response = await this.api.get>( + `/keys/${sourceKeyId}/merge-proposal` + ); + return response.data.data ?? null; + } catch (error) { + throw this.handleError(error); + } + } + + /** + * Cast (or change) a vote on a merge proposal as the connected wallet. + * POST /keys/merge-proposals/:proposalId/votes + */ + async castVote( + proposalId: string, + direction: MergeVoteDirection + ): Promise { + try { + const response = await this.api.post>( + `/keys/merge-proposals/${proposalId}/votes`, + { direction } + ); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const mergeProposalService = new MergeProposalService(); diff --git a/src/types/mergeProposal.ts b/src/types/mergeProposal.ts new file mode 100644 index 00000000..95ae475f --- /dev/null +++ b/src/types/mergeProposal.ts @@ -0,0 +1,61 @@ +/** + * Merge proposal voting types for source key holders (#983). + * + * A merge proposal lets holders of a "source" creator key vote on whether + * that key should be merged into a target key. Voting is weighted by the + * number of source keys held, runs until `votingDeadline`, and passes only + * if the approval share of cast votes clears `approvalThresholdBps`. + */ + +/** Lifecycle status of a merge proposal. */ +export type MergeProposalStatus = + | 'active' + | 'passed' + | 'failed' + | 'pending_execution' + | 'executed'; + +/** Direction of a single holder's merge vote. */ +export type MergeVoteDirection = 'approve' | 'reject'; + +/** + * Merge proposal fetched from the backend for a given source key. + * + * `approveWeight` / `rejectWeight` are already-aggregated vote weights + * (1 unit of weight per source key held at the time of voting). + */ +export interface MergeProposal { + id: string; + /** ID of the source creator key this proposal was raised on. */ + sourceKeyId: string; + /** ID of the key the source key would merge into. */ + targetKeyId: string; + /** Display name of the target key, for the banner copy. */ + targetKeyName?: string; + title: string; + description: string; + status: MergeProposalStatus; + /** Aggregate approve-vote weight. */ + approveWeight: number; + /** Aggregate reject-vote weight. */ + rejectWeight: number; + /** + * Approval threshold in basis points (e.g. 6000 = 60%) of votes cast + * (approve + reject) required for the proposal to pass. + */ + approvalThresholdBps: number; + /** Minimum source keys a wallet must hold to be a qualifying voter. */ + minHoldingToVote: number; + /** Timestamp voting opened. */ + startDate: string; + /** Timestamp voting closes. */ + votingDeadline: string; + /** The connected wallet's own vote direction, if it has already voted. */ + userVote?: MergeVoteDirection | null; +} + +/** Response returned after successfully casting a merge vote. */ +export interface MergeVoteCastResult { + proposal: MergeProposal; + direction: MergeVoteDirection; +} diff --git a/src/utils/mergeProposal.utils.ts b/src/utils/mergeProposal.utils.ts new file mode 100644 index 00000000..ecfa9d6a --- /dev/null +++ b/src/utils/mergeProposal.utils.ts @@ -0,0 +1,140 @@ +import type { + MergeProposal, + MergeProposalStatus, +} from '@/types/mergeProposal'; + +/** Display outcome once voting has closed (post-deadline). */ +export type MergeProposalOutcome = 'passed' | 'failed' | 'pending_execution'; + +/** + * Total weight of votes cast so far (approve + reject). Abstentions don't + * exist for merge votes — a qualifying holder either approves or rejects. + */ +export function getTotalVotesCast( + proposal: Pick +): number { + const approve = Number.isFinite(proposal.approveWeight) + ? proposal.approveWeight + : 0; + const reject = Number.isFinite(proposal.rejectWeight) + ? proposal.rejectWeight + : 0; + return Math.max(0, approve) + Math.max(0, reject); +} + +/** + * Current approval percentage of votes cast, 0-100. Returns 0 when no + * votes have been cast yet, rather than dividing by zero. + */ +export function getApprovalPercentage( + proposal: Pick +): number { + const total = getTotalVotesCast(proposal); + if (total <= 0) return 0; + return (Math.max(0, proposal.approveWeight) / total) * 100; +} + +/** Required approval percentage (0-100) derived from basis points. */ +export function getRequiredThresholdPercentage( + approvalThresholdBps: number +): number { + if (!Number.isFinite(approvalThresholdBps)) return 0; + return Math.min(100, Math.max(0, approvalThresholdBps / 100)); +} + +/** Whether the current tally already clears the pass threshold. */ +export function isApprovalThresholdMet(proposal: MergeProposal): boolean { + return ( + getApprovalPercentage(proposal) >= + getRequiredThresholdPercentage(proposal.approvalThresholdBps) + ); +} + +/** Milliseconds remaining until the voting deadline (never negative). */ +export function getRemainingMs( + votingDeadline: string, + now: number = Date.now() +): number { + const deadline = new Date(votingDeadline).getTime(); + if (Number.isNaN(deadline)) return 0; + return Math.max(0, deadline - now); +} + +/** Whether the voting window is still open. */ +export function isVotingOpen( + proposal: Pick, + now: number = Date.now() +): boolean { + return ( + proposal.status === 'active' && + getRemainingMs(proposal.votingDeadline, now) > 0 + ); +} + +/** Formats a remaining-time duration as e.g. "2d 04h 11m 09s" or "45s". */ +export function formatCountdownDuration(remainingMs: number): string { + if (remainingMs <= 0) return '0s'; + + const totalSeconds = Math.floor(remainingMs / 1000); + const days = Math.floor(totalSeconds / 86_400); + const hours = Math.floor((totalSeconds % 86_400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (days > 0 || hours > 0) parts.push(`${String(hours).padStart(2, '0')}h`); + if (days > 0 || hours > 0 || minutes > 0) { + parts.push(`${String(minutes).padStart(2, '0')}m`); + } + parts.push(`${String(seconds).padStart(2, '0')}s`); + + return parts.join(' '); +} + +/** + * Derives the display outcome for a proposal whose voting window has + * closed. Prefers the authoritative backend `status`; falls back to a + * client-side computation from the tally only when the backend hasn't + * caught up yet (deadline passed but status still reads 'active'). + */ +export function getMergeProposalOutcome( + proposal: MergeProposal, + now: number = Date.now() +): MergeProposalOutcome | null { + const deadlinePassed = getRemainingMs(proposal.votingDeadline, now) <= 0; + + const statusOutcome: Partial> = + { + passed: 'passed', + failed: 'failed', + pending_execution: 'pending_execution', + executed: 'passed', + }; + + if (proposal.status !== 'active') { + return statusOutcome[proposal.status] ?? null; + } + + if (!deadlinePassed) return null; + + // Deadline has passed but the backend hasn't re-classified the + // proposal yet — show a best-effort outcome from the current tally so + // the banner doesn't sit in a stale "active" state. + return isApprovalThresholdMet(proposal) ? 'pending_execution' : 'failed'; +} + +/** + * Whether a wallet qualifies to vote on this proposal: it must hold at + * least `minHoldingToVote` source keys and not have already voted. + */ +export function isQualifyingVoter( + proposal: Pick, + holdingsCount: number +): boolean { + if (proposal.userVote) return false; + const threshold = Number.isFinite(proposal.minHoldingToVote) + ? proposal.minHoldingToVote + : 1; + return holdingsCount >= Math.max(1, threshold); +}