Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 }>();
Expand Down Expand Up @@ -183,7 +184,11 @@ export default function BountyManagementDashboard() {
/>
</TabsContent>
<TabsContent value='settings'>
<TabPlaceholder title='Settings & cancel / refund' issue='#634' />
<BountySettingsPanel
organizationId={organizationId}
bountyId={bountyId}
overview={overview}
/>
</TabsContent>
</Tabs>
</div>
Expand Down Expand Up @@ -354,13 +359,3 @@ function Row({
</div>
);
}

function TabPlaceholder({ title, issue }: { title: string; issue: string }) {
return (
<div className='rounded-2xl border border-dashed border-zinc-800 bg-zinc-900/20 py-16 text-center'>
<Lock className='mx-auto mb-3 h-5 w-5 text-zinc-600' />
<p className='text-sm font-medium text-zinc-300'>{title}</p>
<p className='mt-1 text-xs text-zinc-600'>Coming soon ({issue}).</p>
</div>
);
}
177 changes: 177 additions & 0 deletions components/organization/bounties/manage/BountySettingsPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className='max-w-2xl space-y-6'>
<div className='rounded-2xl border border-red-500/30 bg-red-500/[0.04] p-5'>
<div className='flex items-start gap-3'>
<AlertTriangle className='mt-0.5 h-5 w-5 shrink-0 text-red-400' />
<div className='min-w-0 flex-1'>
<h3 className='text-sm font-semibold text-white'>
Cancel this bounty
</h3>
<p className='mt-1 text-sm text-zinc-400'>
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.
</p>

{cancelOp.isCompleted || isCancelled ? (
<div className='mt-4 space-y-2 rounded-xl border border-zinc-800 bg-zinc-900/50 p-4'>
<div className='flex items-center gap-2 text-sm text-white'>
<CheckCircle2 className='h-4 w-4 text-emerald-400' />
This bounty is cancelled and the escrow was refunded.
</div>
{cancelOp.txHash && (
<a
href={getTransactionExplorerUrl(cancelOp.txHash)}
target='_blank'
rel='noreferrer'
className='text-primary inline-flex items-center gap-1 text-xs hover:underline'
>
View refund transaction
<ExternalLink className='h-3 w-3' />
</a>
)}
</div>
) : running ? (
<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'>
<Loader2 className='text-primary h-4 w-4 animate-spin' />
{PHASE_LABEL[cancelOp.phase] || 'Working…'}
</div>
) : isTerminal ? (
<p className='mt-4 text-sm text-zinc-500'>
This bounty is {overview.status.replace(/_/g, ' ')} and can no
longer be cancelled.
</p>
) : !isPublished ? (
<p className='mt-4 text-sm text-zinc-500'>
This bounty is still a draft with no funded escrow. Delete it
from your drafts instead.
</p>
) : (
<BoundlessButton
variant='destructive'
className='mt-4'
onClick={() => {
setConfirmText('');
setConfirmOpen(true);
}}
>
Cancel bounty &amp; refund
</BoundlessButton>
)}
</div>
</div>
</div>

{/* Explicit-confirm gate before the irreversible on-chain refund. */}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className='border-zinc-800 bg-zinc-950 text-white'>
<DialogHeader>
<DialogTitle>Cancel this bounty?</DialogTitle>
<DialogDescription>
The escrow refunds in one on-chain transaction, contributors
first, then your owner residual. This cannot be undone or
reversed. Type{' '}
<span className='font-mono font-semibold text-red-400'>
{CONFIRM_PHRASE}
</span>{' '}
to confirm.
</DialogDescription>
</DialogHeader>
<div className='space-y-3'>
<div className='rounded-lg border border-zinc-800 bg-zinc-900/40 p-3 text-sm'>
<div className='flex items-center justify-between'>
<span className='text-zinc-400'>Reward pool</span>
<span className='font-semibold text-white'>
{overview.rewardAmount.toLocaleString()}{' '}
{overview.rewardCurrency}
</span>
</div>
</div>
<Input
autoFocus
value={confirmText}
onChange={e => setConfirmText(e.target.value)}
placeholder={`Type ${CONFIRM_PHRASE} to confirm`}
className='border-zinc-800 bg-zinc-900/50 text-white placeholder:text-zinc-600'
/>
</div>
<DialogFooter>
<BoundlessButton
variant='outline'
onClick={() => setConfirmOpen(false)}
>
Keep bounty
</BoundlessButton>
<BoundlessButton
variant='destructive'
disabled={confirmText.trim() !== CONFIRM_PHRASE}
onClick={() => {
setConfirmOpen(false);
void cancelOp.cancel();
}}
>
Cancel bounty &amp; refund
</BoundlessButton>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
110 changes: 110 additions & 0 deletions hooks/use-bounty-cancel.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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,
};
};
Loading