Skip to content

Commit 68be515

Browse files
authored
feat(bounty): cancel / refund flow in settings tab (#634) (#674)
Add the Settings tab cancel action for the operate dashboard: a confirm gate explaining the refund order (contributors first, then owner residual), driving the cancel escrow op through useEscrowOpRunner to CANCELLED and surfacing the refund transaction on settle.
1 parent 067746e commit 68be515

3 files changed

Lines changed: 294 additions & 12 deletions

File tree

components/organization/bounties/manage/BountyManagementDashboard.tsx

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useState } from 'react';
44
import { useParams } from 'next/navigation';
55
import Link from 'next/link';
6-
import { ArrowLeft, Info, Loader2, Lock } from 'lucide-react';
6+
import { ArrowLeft, Info, Loader2 } from 'lucide-react';
77

88
import { Badge } from '@/components/ui/badge';
99
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -27,6 +27,7 @@ import { ordinal } from '@/lib/utils';
2727
import BountySubmissionsPanel from './BountySubmissionsPanel';
2828
import BountyPayoutPanel from './BountyPayoutPanel';
2929
import BountyApplicationsPanel from './BountyApplicationsPanel';
30+
import BountySettingsPanel from './BountySettingsPanel';
3031

3132
export default function BountyManagementDashboard() {
3233
const params = useParams<{ id: string; bountyId: string }>();
@@ -183,7 +184,11 @@ export default function BountyManagementDashboard() {
183184
/>
184185
</TabsContent>
185186
<TabsContent value='settings'>
186-
<TabPlaceholder title='Settings & cancel / refund' issue='#634' />
187+
<BountySettingsPanel
188+
organizationId={organizationId}
189+
bountyId={bountyId}
190+
overview={overview}
191+
/>
187192
</TabsContent>
188193
</Tabs>
189194
</div>
@@ -354,13 +359,3 @@ function Row({
354359
</div>
355360
);
356361
}
357-
358-
function TabPlaceholder({ title, issue }: { title: string; issue: string }) {
359-
return (
360-
<div className='rounded-2xl border border-dashed border-zinc-800 bg-zinc-900/20 py-16 text-center'>
361-
<Lock className='mx-auto mb-3 h-5 w-5 text-zinc-600' />
362-
<p className='text-sm font-medium text-zinc-300'>{title}</p>
363-
<p className='mt-1 text-xs text-zinc-600'>Coming soon ({issue}).</p>
364-
</div>
365-
);
366-
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import {
5+
AlertTriangle,
6+
CheckCircle2,
7+
ExternalLink,
8+
Loader2,
9+
} from 'lucide-react';
10+
11+
import {
12+
Dialog,
13+
DialogContent,
14+
DialogDescription,
15+
DialogFooter,
16+
DialogHeader,
17+
DialogTitle,
18+
} from '@/components/ui/dialog';
19+
import { Input } from '@/components/ui/input';
20+
import { BoundlessButton } from '@/components/buttons';
21+
import {
22+
ESCROW_PHASE_LABEL,
23+
type BountyOperateOverview,
24+
} from '@/features/bounties';
25+
import { useBountyCancel } from '@/hooks/use-bounty-cancel';
26+
import { getTransactionExplorerUrl } from '@/lib/wallet-utils';
27+
28+
const PHASE_LABEL = {
29+
...ESCROW_PHASE_LABEL,
30+
starting: 'Preparing cancellation…',
31+
polling: 'Refunding escrow on-chain…',
32+
};
33+
34+
/** Lifecycle statuses that mean the bounty can no longer be cancelled. */
35+
const TERMINAL_STATUSES = new Set(['completed', 'cancelled']);
36+
37+
const CONFIRM_PHRASE = 'CANCEL';
38+
39+
export default function BountySettingsPanel({
40+
organizationId,
41+
bountyId,
42+
overview,
43+
}: {
44+
organizationId: string;
45+
bountyId: string;
46+
overview: BountyOperateOverview;
47+
}) {
48+
const cancelOp = useBountyCancel({ organizationId, bountyId });
49+
const [confirmOpen, setConfirmOpen] = useState(false);
50+
const [confirmText, setConfirmText] = useState('');
51+
52+
const isCancelled = overview.status === 'cancelled';
53+
const isTerminal = TERMINAL_STATUSES.has(overview.status);
54+
// A draft has no on-chain escrow to refund; cancel only applies once funded.
55+
const isPublished = !!overview.escrowEventId;
56+
const running = cancelOp.isRunning;
57+
58+
return (
59+
<div className='max-w-2xl space-y-6'>
60+
<div className='rounded-2xl border border-red-500/30 bg-red-500/[0.04] p-5'>
61+
<div className='flex items-start gap-3'>
62+
<AlertTriangle className='mt-0.5 h-5 w-5 shrink-0 text-red-400' />
63+
<div className='min-w-0 flex-1'>
64+
<h3 className='text-sm font-semibold text-white'>
65+
Cancel this bounty
66+
</h3>
67+
<p className='mt-1 text-sm text-zinc-400'>
68+
Cancelling closes the bounty and refunds the locked escrow.
69+
Contributors are refunded first, then any residual returns to you
70+
as the owner. This runs once on-chain and cannot be undone.
71+
</p>
72+
73+
{cancelOp.isCompleted || isCancelled ? (
74+
<div className='mt-4 space-y-2 rounded-xl border border-zinc-800 bg-zinc-900/50 p-4'>
75+
<div className='flex items-center gap-2 text-sm text-white'>
76+
<CheckCircle2 className='h-4 w-4 text-emerald-400' />
77+
This bounty is cancelled and the escrow was refunded.
78+
</div>
79+
{cancelOp.txHash && (
80+
<a
81+
href={getTransactionExplorerUrl(cancelOp.txHash)}
82+
target='_blank'
83+
rel='noreferrer'
84+
className='text-primary inline-flex items-center gap-1 text-xs hover:underline'
85+
>
86+
View refund transaction
87+
<ExternalLink className='h-3 w-3' />
88+
</a>
89+
)}
90+
</div>
91+
) : running ? (
92+
<div className='mt-4 flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 text-sm text-zinc-300'>
93+
<Loader2 className='text-primary h-4 w-4 animate-spin' />
94+
{PHASE_LABEL[cancelOp.phase] || 'Working…'}
95+
</div>
96+
) : isTerminal ? (
97+
<p className='mt-4 text-sm text-zinc-500'>
98+
This bounty is {overview.status.replace(/_/g, ' ')} and can no
99+
longer be cancelled.
100+
</p>
101+
) : !isPublished ? (
102+
<p className='mt-4 text-sm text-zinc-500'>
103+
This bounty is still a draft with no funded escrow. Delete it
104+
from your drafts instead.
105+
</p>
106+
) : (
107+
<BoundlessButton
108+
variant='destructive'
109+
className='mt-4'
110+
onClick={() => {
111+
setConfirmText('');
112+
setConfirmOpen(true);
113+
}}
114+
>
115+
Cancel bounty &amp; refund
116+
</BoundlessButton>
117+
)}
118+
</div>
119+
</div>
120+
</div>
121+
122+
{/* Explicit-confirm gate before the irreversible on-chain refund. */}
123+
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
124+
<DialogContent className='border-zinc-800 bg-zinc-950 text-white'>
125+
<DialogHeader>
126+
<DialogTitle>Cancel this bounty?</DialogTitle>
127+
<DialogDescription>
128+
The escrow refunds in one on-chain transaction, contributors
129+
first, then your owner residual. This cannot be undone or
130+
reversed. Type{' '}
131+
<span className='font-mono font-semibold text-red-400'>
132+
{CONFIRM_PHRASE}
133+
</span>{' '}
134+
to confirm.
135+
</DialogDescription>
136+
</DialogHeader>
137+
<div className='space-y-3'>
138+
<div className='rounded-lg border border-zinc-800 bg-zinc-900/40 p-3 text-sm'>
139+
<div className='flex items-center justify-between'>
140+
<span className='text-zinc-400'>Reward pool</span>
141+
<span className='font-semibold text-white'>
142+
{overview.rewardAmount.toLocaleString()}{' '}
143+
{overview.rewardCurrency}
144+
</span>
145+
</div>
146+
</div>
147+
<Input
148+
autoFocus
149+
value={confirmText}
150+
onChange={e => setConfirmText(e.target.value)}
151+
placeholder={`Type ${CONFIRM_PHRASE} to confirm`}
152+
className='border-zinc-800 bg-zinc-900/50 text-white placeholder:text-zinc-600'
153+
/>
154+
</div>
155+
<DialogFooter>
156+
<BoundlessButton
157+
variant='outline'
158+
onClick={() => setConfirmOpen(false)}
159+
>
160+
Keep bounty
161+
</BoundlessButton>
162+
<BoundlessButton
163+
variant='destructive'
164+
disabled={confirmText.trim() !== CONFIRM_PHRASE}
165+
onClick={() => {
166+
setConfirmOpen(false);
167+
void cancelOp.cancel();
168+
}}
169+
>
170+
Cancel bounty &amp; refund
171+
</BoundlessButton>
172+
</DialogFooter>
173+
</DialogContent>
174+
</Dialog>
175+
</div>
176+
);
177+
}

hooks/use-bounty-cancel.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { useEffect, useRef } from 'react';
2+
import { useQueryClient } from '@tanstack/react-query';
3+
import { toast } from 'sonner';
4+
5+
import { useWalletContext } from '@/components/providers/wallet-provider';
6+
import { signXdrWithKit } from '@/lib/wallet/wallet-kit';
7+
import {
8+
bountyKeys,
9+
useCancelBountyEscrow,
10+
useEscrowOpRunner,
11+
type CancelBountyEscrowRequest,
12+
type FundingMode,
13+
} from '@/features/bounties';
14+
15+
interface UseBountyCancelProps {
16+
organizationId: string;
17+
bountyId: string;
18+
/** Signing path. Defaults to MANAGED (custodial). */
19+
fundingMode?: FundingMode;
20+
/** For EXTERNAL: the connected wallet that signs (owner of the escrow). */
21+
externalOwnerAddress?: string | null;
22+
}
23+
24+
/**
25+
* Drives the single `cancel` op that aborts a published bounty and refunds the
26+
* escrow (contributors first, then the owner residual). Mirrors useBountyPayout:
27+
* MANAGED signs server-side (op comes back PENDING_CONFIRM), EXTERNAL returns an
28+
* unsigned XDR the connected wallet signs. On settle the backend subscriber
29+
* pushes the refunds and flips the bounty to CANCELLED; we invalidate the
30+
* overview so the dashboard reflects the new state immediately.
31+
*/
32+
export const useBountyCancel = ({
33+
organizationId,
34+
bountyId,
35+
fundingMode = 'MANAGED',
36+
externalOwnerAddress,
37+
}: UseBountyCancelProps) => {
38+
const { walletAddress } = useWalletContext();
39+
const queryClient = useQueryClient();
40+
const isExternal = fundingMode === 'EXTERNAL';
41+
const ownerAddress = isExternal
42+
? (externalOwnerAddress ?? null)
43+
: walletAddress;
44+
45+
const cancelMutation = useCancelBountyEscrow(organizationId, bountyId);
46+
const runner = useEscrowOpRunner(
47+
{ kind: 'organizer', organizationId, bountyId },
48+
isExternal ? { signXdr: signXdrWithKit } : undefined
49+
);
50+
51+
// Toast each settle exactly once; the runner keeps its terminal flags set
52+
// after a run, so this ref is what arms the next run's notifications.
53+
const finalizedRef = useRef(false);
54+
55+
useEffect(() => {
56+
if (finalizedRef.current) return;
57+
if (runner.isCompleted) {
58+
finalizedRef.current = true;
59+
toast.success('Bounty cancelled and escrow refunded.', {
60+
duration: 3000,
61+
});
62+
void queryClient.invalidateQueries({
63+
queryKey: bountyKeys.overview(organizationId, bountyId),
64+
});
65+
} else if (runner.isFailed) {
66+
finalizedRef.current = true;
67+
toast.error(runner.error || 'Failed to cancel the bounty');
68+
}
69+
}, [
70+
runner.isCompleted,
71+
runner.isFailed,
72+
runner.error,
73+
queryClient,
74+
organizationId,
75+
bountyId,
76+
]);
77+
78+
const cancel = async (): Promise<void> => {
79+
if (!organizationId || !bountyId) return;
80+
if (!ownerAddress) {
81+
toast.error(
82+
isExternal
83+
? 'Connect a wallet to sign the cancellation.'
84+
: 'Please connect your wallet first'
85+
);
86+
return;
87+
}
88+
89+
const body: CancelBountyEscrowRequest = {
90+
ownerAddress,
91+
fundingMode,
92+
};
93+
94+
finalizedRef.current = false;
95+
toast.info('Submitting cancellation…');
96+
97+
await runner.run(() => cancelMutation.mutateAsync(body));
98+
};
99+
100+
return {
101+
cancel,
102+
phase: runner.phase,
103+
isRunning: runner.isRunning,
104+
isCompleted: runner.isCompleted,
105+
isFailed: runner.isFailed,
106+
txHash: runner.txHash,
107+
error: runner.error,
108+
reset: runner.reset,
109+
};
110+
};

0 commit comments

Comments
 (0)