Skip to content

Commit 37aa7d6

Browse files
authored
feat(bounty): apply / join / claim flow + edit/withdraw (mode-aware) (#647)
Fill the entry execution the detail-page CTA hands off to (#623), wired to the participant data layer (#621). Covers all six modes. - applicationSchema: mode-aware zod validation + body builders (light: proposalShort 100-300 words + estimatedDays + <=3 links; full: proposalFull 500-2000 words + qualifications >=50 chars + <=6 links + optional video) - BountyApplicationForm: conditional fields per entry type with live word count; reused for create + edit - BountyEntryDialog: mode-aware modal — Apply (POST v2/applications) / Edit (PATCH); Join (POST v2/competition/join); Claim (on-chain apply escrow op, MANAGED, with progress + reputation pre-check). Wallet-gated. - BountyEntryCta: opens the dialog and, for an active application, exposes Edit + Withdraw (application), Leave (competition), or on-chain Withdraw (single claim) behind a two-step confirm Closes #624
1 parent 928aa73 commit 37aa7d6

4 files changed

Lines changed: 794 additions & 40 deletions

File tree

Lines changed: 147 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,27 @@
11
'use client';
22

3+
import { useEffect, useState } from 'react';
34
import { toast } from 'sonner';
4-
import { CheckCircle2, Lock } from 'lucide-react';
5+
import { CheckCircle2, Loader2, Lock } from 'lucide-react';
56

67
import { BoundlessButton } from '@/components/buttons';
78
import { Badge } from '@/components/ui/badge';
8-
import type { BountyPublic, MyBountyApplication } from '@/features/bounties';
9+
import { useWalletContext } from '@/components/providers/wallet-provider';
10+
import {
11+
useLeaveCompetition,
12+
useWithdrawApplication,
13+
useWithdrawApplicationEscrow,
14+
type BountyPublic,
15+
type MyBountyApplication,
16+
} from '@/features/bounties';
17+
import BountyEntryDialog from './entry/BountyEntryDialog';
918

10-
/** Statuses where a bounty still accepts new entries. */
1119
const ACCEPTING_STATUSES = new Set([
1220
'open',
1321
'open_for_applications',
1422
'upcoming',
1523
]);
1624

17-
type EntryKind = 'claim' | 'join' | 'apply' | 'view';
18-
19-
function entryKind(b: BountyPublic): { kind: EntryKind; label: string } {
20-
const open = b.entryType === 'OPEN';
21-
const application =
22-
b.entryType === 'APPLICATION_LIGHT' || b.entryType === 'APPLICATION_FULL';
23-
if (open && b.claimType === 'SINGLE_CLAIM')
24-
return { kind: 'claim', label: 'Claim this bounty' };
25-
if (open && b.claimType === 'COMPETITION')
26-
return { kind: 'join', label: 'Join competition' };
27-
if (application) return { kind: 'apply', label: 'Apply' };
28-
return { kind: 'view', label: 'View' };
29-
}
30-
3125
const STATUS_LABEL: Record<string, string> = {
3226
SUBMITTED: 'Application submitted',
3327
SHORTLISTED: 'Shortlisted',
@@ -51,53 +45,158 @@ export function BountyEntryCta({
5145
bounty: BountyPublic;
5246
myApplication: MyBountyApplication | null;
5347
}) {
54-
const { label } = entryKind(bounty);
48+
const { walletAddress } = useWalletContext();
49+
const [dialogOpen, setDialogOpen] = useState(false);
50+
const [editing, setEditing] = useState(false);
51+
const [confirmWithdraw, setConfirmWithdraw] = useState(false);
52+
53+
const withdrawApp = useWithdrawApplication(bounty.id);
54+
const leave = useLeaveCompetition(bounty.id);
55+
const withdrawClaim = useWithdrawApplicationEscrow(bounty.id);
56+
57+
const isApplication =
58+
bounty.entryType === 'APPLICATION_LIGHT' ||
59+
bounty.entryType === 'APPLICATION_FULL';
60+
const isCompetition =
61+
bounty.entryType === 'OPEN' && bounty.claimType === 'COMPETITION';
62+
63+
const primaryLabel = isApplication
64+
? 'Apply'
65+
: isCompetition
66+
? 'Join competition'
67+
: 'Claim this bounty';
5568

5669
const deadlinePassed =
5770
!!bounty.applicationWindowCloseAt &&
5871
new Date(bounty.applicationWindowCloseAt).getTime() < Date.now();
5972
const accepting = ACCEPTING_STATUSES.has(bounty.status);
6073

61-
// An active (non-withdrawn) application means the caller is already in.
62-
const activeStatus =
63-
myApplication && myApplication.applicationStatus !== 'WITHDRAWN'
64-
? myApplication.applicationStatus
65-
: null;
74+
const withdrawn =
75+
!!myApplication &&
76+
(myApplication.applicationStatus === 'WITHDRAWN' ||
77+
myApplication.status === 'withdrawn');
78+
const isActive = !!myApplication && !withdrawn;
79+
// Application records are only mutable while SUBMITTED.
80+
const editable =
81+
isApplication && myApplication?.applicationStatus === 'SUBMITTED';
82+
83+
useEffect(() => {
84+
if (withdrawClaim.isCompleted) toast.success('Claim withdrawn.');
85+
else if (withdrawClaim.isFailed)
86+
toast.error(withdrawClaim.error || 'Withdraw failed.');
87+
}, [withdrawClaim.isCompleted, withdrawClaim.isFailed, withdrawClaim.error]);
6688

67-
// The real entry flow (forms / on-chain op) ships in #624; the CTA here is
68-
// mode-aware and correctly gated, and hands off to that flow.
69-
const onEntry = () =>
70-
toast.info('The entry flow is coming in the next step (apply/join/claim).');
89+
const handleWithdraw = () => {
90+
if (isApplication) {
91+
if (!myApplication) return;
92+
withdrawApp.mutate(myApplication.id, {
93+
onSuccess: () => toast.success('Application withdrawn.'),
94+
onError: e =>
95+
toast.error((e as Error).message || 'Failed to withdraw.'),
96+
});
97+
} else if (isCompetition) {
98+
if (!walletAddress) return toast.error('Connect a wallet to leave.');
99+
leave.mutate(
100+
{ applicantAddress: walletAddress },
101+
{
102+
onSuccess: () => toast.success('You left the competition.'),
103+
onError: e => toast.error((e as Error).message || 'Failed to leave.'),
104+
}
105+
);
106+
} else {
107+
if (!walletAddress) return toast.error('Connect a wallet to withdraw.');
108+
void withdrawClaim.run({ applicantAddress: walletAddress });
109+
}
110+
setConfirmWithdraw(false);
111+
};
112+
113+
const withdrawPending =
114+
withdrawApp.isPending || leave.isPending || withdrawClaim.isRunning;
115+
const withdrawLabel = isCompetition ? 'Leave competition' : 'Withdraw';
71116

72117
return (
73118
<div className='rounded-2xl border border-zinc-800 bg-zinc-900/40 p-5'>
74-
{activeStatus ? (
119+
{isActive ? (
75120
<div className='space-y-3'>
76121
<div className='flex items-center gap-2'>
77122
<CheckCircle2 className='text-primary h-4 w-4' />
78123
<span className='text-sm font-medium text-white'>
79124
You&apos;re in this bounty
80125
</span>
81126
</div>
82-
<Badge
83-
variant='outline'
84-
className={`rounded-full px-3 py-1 text-xs font-medium ${
85-
STATUS_CLASS[activeStatus] ??
86-
'border-zinc-700 bg-zinc-800/60 text-zinc-300'
87-
}`}
88-
>
89-
{STATUS_LABEL[activeStatus] ?? activeStatus}
90-
</Badge>
127+
{myApplication?.applicationStatus && (
128+
<Badge
129+
variant='outline'
130+
className={`rounded-full px-3 py-1 text-xs font-medium ${
131+
STATUS_CLASS[myApplication.applicationStatus] ??
132+
'border-zinc-700 bg-zinc-800/60 text-zinc-300'
133+
}`}
134+
>
135+
{STATUS_LABEL[myApplication.applicationStatus] ??
136+
myApplication.applicationStatus}
137+
</Badge>
138+
)}
139+
140+
{/* Edit (application, while SUBMITTED) */}
141+
{editable && (
142+
<BoundlessButton
143+
variant='outline'
144+
className='w-full'
145+
onClick={() => {
146+
setEditing(true);
147+
setDialogOpen(true);
148+
}}
149+
>
150+
Edit application
151+
</BoundlessButton>
152+
)}
153+
154+
{/* Withdraw / leave (two-step confirm) */}
155+
{(editable || isCompetition || !isApplication) &&
156+
(confirmWithdraw ? (
157+
<div className='flex gap-2'>
158+
<BoundlessButton
159+
variant='outline'
160+
className='flex-1'
161+
onClick={() => setConfirmWithdraw(false)}
162+
disabled={withdrawPending}
163+
>
164+
Cancel
165+
</BoundlessButton>
166+
<BoundlessButton
167+
className='flex-1 bg-red-500 text-white hover:bg-red-600'
168+
onClick={handleWithdraw}
169+
disabled={withdrawPending}
170+
>
171+
{withdrawPending ? (
172+
<Loader2 className='h-4 w-4 animate-spin' />
173+
) : (
174+
'Confirm'
175+
)}
176+
</BoundlessButton>
177+
</div>
178+
) : (
179+
<BoundlessButton
180+
variant='outline'
181+
className='w-full text-red-400 hover:bg-red-500/10 hover:text-red-300'
182+
onClick={() => setConfirmWithdraw(true)}
183+
>
184+
{withdrawLabel}
185+
</BoundlessButton>
186+
))}
91187
</div>
92188
) : (
93189
<>
94190
<BoundlessButton
95191
size='lg'
96192
className='w-full'
97193
disabled={!accepting || deadlinePassed}
98-
onClick={onEntry}
194+
onClick={() => {
195+
setEditing(false);
196+
setDialogOpen(true);
197+
}}
99198
>
100-
{label}
199+
{primaryLabel}
101200
</BoundlessButton>
102201
<p className='mt-2 flex items-center justify-center gap-1.5 text-center text-xs text-zinc-500'>
103202
{!accepting ? (
@@ -110,14 +209,22 @@ export function BountyEntryCta({
110209
<Lock className='h-3.5 w-3.5' />
111210
Applications have closed.
112211
</>
113-
) : myApplication?.applicationStatus === 'WITHDRAWN' ? (
212+
) : withdrawn ? (
114213
'You withdrew earlier — you can enter again.'
115214
) : (
116215
'Funds are held in escrow and paid on-chain to winners.'
117216
)}
118217
</p>
119218
</>
120219
)}
220+
221+
<BountyEntryDialog
222+
bounty={bounty}
223+
existingApplication={myApplication}
224+
editing={editing}
225+
open={dialogOpen}
226+
onOpenChange={setDialogOpen}
227+
/>
121228
</div>
122229
);
123230
}

0 commit comments

Comments
 (0)