11'use client' ;
22
3+ import { useEffect , useState } from 'react' ;
34import { toast } from 'sonner' ;
4- import { CheckCircle2 , Lock } from 'lucide-react' ;
5+ import { CheckCircle2 , Loader2 , Lock } from 'lucide-react' ;
56
67import { BoundlessButton } from '@/components/buttons' ;
78import { 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. */
1119const 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-
3125const 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'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