diff --git a/components/organization/bounties/manage/BountyManagementDashboard.tsx b/components/organization/bounties/manage/BountyManagementDashboard.tsx index 09f792d3..448ec9f5 100644 --- a/components/organization/bounties/manage/BountyManagementDashboard.tsx +++ b/components/organization/bounties/manage/BountyManagementDashboard.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { useParams } from 'next/navigation'; import Link from 'next/link'; -import { ArrowLeft, Info, Loader2, Lock } from 'lucide-react'; +import { ArrowLeft, Info, Loader2 } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; @@ -27,6 +27,7 @@ import { ordinal } from '@/lib/utils'; import BountySubmissionsPanel from './BountySubmissionsPanel'; import BountyPayoutPanel from './BountyPayoutPanel'; import BountyApplicationsPanel from './BountyApplicationsPanel'; +import BountySettingsPanel from './BountySettingsPanel'; export default function BountyManagementDashboard() { const params = useParams<{ id: string; bountyId: string }>(); @@ -183,7 +184,11 @@ export default function BountyManagementDashboard() { /> - + @@ -354,13 +359,3 @@ function Row({ ); } - -function TabPlaceholder({ title, issue }: { title: string; issue: string }) { - return ( -
- -

{title}

-

Coming soon ({issue}).

-
- ); -} diff --git a/components/organization/bounties/manage/BountySettingsPanel.tsx b/components/organization/bounties/manage/BountySettingsPanel.tsx new file mode 100644 index 00000000..8d1c8c62 --- /dev/null +++ b/components/organization/bounties/manage/BountySettingsPanel.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { useState } from 'react'; +import { + AlertTriangle, + CheckCircle2, + ExternalLink, + Loader2, +} from 'lucide-react'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { BoundlessButton } from '@/components/buttons'; +import { + ESCROW_PHASE_LABEL, + type BountyOperateOverview, +} from '@/features/bounties'; +import { useBountyCancel } from '@/hooks/use-bounty-cancel'; +import { getTransactionExplorerUrl } from '@/lib/wallet-utils'; + +const PHASE_LABEL = { + ...ESCROW_PHASE_LABEL, + starting: 'Preparing cancellation…', + polling: 'Refunding escrow on-chain…', +}; + +/** Lifecycle statuses that mean the bounty can no longer be cancelled. */ +const TERMINAL_STATUSES = new Set(['completed', 'cancelled']); + +const CONFIRM_PHRASE = 'CANCEL'; + +export default function BountySettingsPanel({ + organizationId, + bountyId, + overview, +}: { + organizationId: string; + bountyId: string; + overview: BountyOperateOverview; +}) { + const cancelOp = useBountyCancel({ organizationId, bountyId }); + const [confirmOpen, setConfirmOpen] = useState(false); + const [confirmText, setConfirmText] = useState(''); + + const isCancelled = overview.status === 'cancelled'; + const isTerminal = TERMINAL_STATUSES.has(overview.status); + // A draft has no on-chain escrow to refund; cancel only applies once funded. + const isPublished = !!overview.escrowEventId; + const running = cancelOp.isRunning; + + return ( +
+
+
+ +
+

+ Cancel this bounty +

+

+ Cancelling closes the bounty and refunds the locked escrow. + Contributors are refunded first, then any residual returns to you + as the owner. This runs once on-chain and cannot be undone. +

+ + {cancelOp.isCompleted || isCancelled ? ( +
+
+ + This bounty is cancelled and the escrow was refunded. +
+ {cancelOp.txHash && ( + + View refund transaction + + + )} +
+ ) : running ? ( +
+ + {PHASE_LABEL[cancelOp.phase] || 'Working…'} +
+ ) : isTerminal ? ( +

+ This bounty is {overview.status.replace(/_/g, ' ')} and can no + longer be cancelled. +

+ ) : !isPublished ? ( +

+ This bounty is still a draft with no funded escrow. Delete it + from your drafts instead. +

+ ) : ( + { + setConfirmText(''); + setConfirmOpen(true); + }} + > + Cancel bounty & refund + + )} +
+
+
+ + {/* Explicit-confirm gate before the irreversible on-chain refund. */} + + + + Cancel this bounty? + + The escrow refunds in one on-chain transaction, contributors + first, then your owner residual. This cannot be undone or + reversed. Type{' '} + + {CONFIRM_PHRASE} + {' '} + to confirm. + + +
+
+
+ Reward pool + + {overview.rewardAmount.toLocaleString()}{' '} + {overview.rewardCurrency} + +
+
+ setConfirmText(e.target.value)} + placeholder={`Type ${CONFIRM_PHRASE} to confirm`} + className='border-zinc-800 bg-zinc-900/50 text-white placeholder:text-zinc-600' + /> +
+ + setConfirmOpen(false)} + > + Keep bounty + + { + setConfirmOpen(false); + void cancelOp.cancel(); + }} + > + Cancel bounty & refund + + +
+
+
+ ); +} diff --git a/hooks/use-bounty-cancel.ts b/hooks/use-bounty-cancel.ts new file mode 100644 index 00000000..156f5904 --- /dev/null +++ b/hooks/use-bounty-cancel.ts @@ -0,0 +1,110 @@ +import { useEffect, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { useWalletContext } from '@/components/providers/wallet-provider'; +import { signXdrWithKit } from '@/lib/wallet/wallet-kit'; +import { + bountyKeys, + useCancelBountyEscrow, + useEscrowOpRunner, + type CancelBountyEscrowRequest, + type FundingMode, +} from '@/features/bounties'; + +interface UseBountyCancelProps { + organizationId: string; + bountyId: string; + /** Signing path. Defaults to MANAGED (custodial). */ + fundingMode?: FundingMode; + /** For EXTERNAL: the connected wallet that signs (owner of the escrow). */ + externalOwnerAddress?: string | null; +} + +/** + * Drives the single `cancel` op that aborts a published bounty and refunds the + * escrow (contributors first, then the owner residual). Mirrors useBountyPayout: + * MANAGED signs server-side (op comes back PENDING_CONFIRM), EXTERNAL returns an + * unsigned XDR the connected wallet signs. On settle the backend subscriber + * pushes the refunds and flips the bounty to CANCELLED; we invalidate the + * overview so the dashboard reflects the new state immediately. + */ +export const useBountyCancel = ({ + organizationId, + bountyId, + fundingMode = 'MANAGED', + externalOwnerAddress, +}: UseBountyCancelProps) => { + const { walletAddress } = useWalletContext(); + const queryClient = useQueryClient(); + const isExternal = fundingMode === 'EXTERNAL'; + const ownerAddress = isExternal + ? (externalOwnerAddress ?? null) + : walletAddress; + + const cancelMutation = useCancelBountyEscrow(organizationId, bountyId); + const runner = useEscrowOpRunner( + { kind: 'organizer', organizationId, bountyId }, + isExternal ? { signXdr: signXdrWithKit } : undefined + ); + + // Toast each settle exactly once; the runner keeps its terminal flags set + // after a run, so this ref is what arms the next run's notifications. + const finalizedRef = useRef(false); + + useEffect(() => { + if (finalizedRef.current) return; + if (runner.isCompleted) { + finalizedRef.current = true; + toast.success('Bounty cancelled and escrow refunded.', { + duration: 3000, + }); + void queryClient.invalidateQueries({ + queryKey: bountyKeys.overview(organizationId, bountyId), + }); + } else if (runner.isFailed) { + finalizedRef.current = true; + toast.error(runner.error || 'Failed to cancel the bounty'); + } + }, [ + runner.isCompleted, + runner.isFailed, + runner.error, + queryClient, + organizationId, + bountyId, + ]); + + const cancel = async (): Promise => { + if (!organizationId || !bountyId) return; + if (!ownerAddress) { + toast.error( + isExternal + ? 'Connect a wallet to sign the cancellation.' + : 'Please connect your wallet first' + ); + return; + } + + const body: CancelBountyEscrowRequest = { + ownerAddress, + fundingMode, + }; + + finalizedRef.current = false; + toast.info('Submitting cancellation…'); + + await runner.run(() => cancelMutation.mutateAsync(body)); + }; + + return { + cancel, + phase: runner.phase, + isRunning: runner.isRunning, + isCompleted: runner.isCompleted, + isFailed: runner.isFailed, + txHash: runner.txHash, + error: runner.error, + reset: runner.reset, + }; +};