Skip to content

Commit c76b521

Browse files
authored
feat(bounty): submit work (on-chain anchor) + withdraw submission (#649)
After entry (claim/join/selection) the builder submits their deliverable, anchored on-chain via the participant escrow runner (#621). Mirrors the hackathon submission anchor. - BountySubmitPanel (detail sidebar, shown when eligible: claimed/joined/ SELECTED/SHORTLISTED): contentUri deliverable input -> submit escrow op (useSubmitBounty, MANAGED) with anchor progress (preparing -> anchoring -> confirmed) + tx-hash explorer link - Withdraw submission via useWithdrawSubmission behind a two-step confirm - Wallet-gated; guarded by entry eligibility Persistent submission state across reloads is session-local until the #332 my-submission read lands in the schema; the submission-deadline gate is enforced on-chain (not in BountyPublicDto). Closes #625
1 parent 37aa7d6 commit c76b521

2 files changed

Lines changed: 228 additions & 0 deletions

File tree

components/bounties/detail/BountyDetail.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import EmptyState from '@/components/EmptyState';
1717
import { computeBountyModeLabel } from '@/components/organization/bounties/new/tabs/schemas/modeSchema';
1818
import { useBounty, useMyBountyApplication } from '@/features/bounties';
1919
import { BountyEntryCta } from './BountyEntryCta';
20+
import BountySubmitPanel from './submit/BountySubmitPanel';
2021

2122
// Markdown renderer (matches the wizard's editor package).
2223
const Markdown = dynamic(
@@ -204,6 +205,12 @@ export default function BountyDetail({ id }: { id: string }) {
204205
bounty={bounty}
205206
myApplication={myApplication ?? null}
206207
/>
208+
209+
{/* Submit work (shown when the builder is eligible) */}
210+
<BountySubmitPanel
211+
bounty={bounty}
212+
myApplication={myApplication ?? null}
213+
/>
207214
</aside>
208215
</div>
209216
</div>
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
'use client';
2+
3+
import { useEffect, useState } from 'react';
4+
import { toast } from 'sonner';
5+
import {
6+
CheckCircle2,
7+
ExternalLink,
8+
Loader2,
9+
Send,
10+
Wallet,
11+
} from 'lucide-react';
12+
13+
import { BoundlessButton } from '@/components/buttons';
14+
import { Input } from '@/components/ui/input';
15+
import { useWalletContext } from '@/components/providers/wallet-provider';
16+
import {
17+
useSubmitBounty,
18+
useWithdrawSubmission,
19+
type BountyPublic,
20+
type MyBountyApplication,
21+
type EscrowRunPhase,
22+
} from '@/features/bounties';
23+
24+
/** Statuses where a builder is entitled to submit work. */
25+
function canSubmit(
26+
bounty: BountyPublic,
27+
app: MyBountyApplication | null
28+
): boolean {
29+
if (!app) return false;
30+
const withdrawn =
31+
app.applicationStatus === 'WITHDRAWN' || app.status === 'withdrawn';
32+
if (withdrawn) return false;
33+
const isApplication =
34+
bounty.entryType === 'APPLICATION_LIGHT' ||
35+
bounty.entryType === 'APPLICATION_FULL';
36+
if (isApplication) {
37+
return (
38+
app.applicationStatus === 'SELECTED' ||
39+
app.applicationStatus === 'SHORTLISTED'
40+
);
41+
}
42+
// Open single claim / competition: claimed or joined (escrow active).
43+
return app.status === 'active' || app.applicationStatus === 'SELECTED';
44+
}
45+
46+
const PHASE_LABEL: Record<EscrowRunPhase, string> = {
47+
idle: '',
48+
starting: 'Preparing submission…',
49+
signing: 'Signing…',
50+
submitting: 'Submitting…',
51+
polling: 'Anchoring on-chain…',
52+
completed: 'Confirmed',
53+
failed: 'Failed',
54+
};
55+
56+
const isUrl = (s: string): boolean => {
57+
try {
58+
new URL(s);
59+
return true;
60+
} catch {
61+
return false;
62+
}
63+
};
64+
65+
export default function BountySubmitPanel({
66+
bounty,
67+
myApplication,
68+
}: {
69+
bounty: BountyPublic;
70+
myApplication: MyBountyApplication | null;
71+
}) {
72+
const { walletAddress } = useWalletContext();
73+
const submit = useSubmitBounty(bounty.id);
74+
const withdraw = useWithdrawSubmission(bounty.id);
75+
76+
const [contentUri, setContentUri] = useState('');
77+
const [touched, setTouched] = useState(false);
78+
// Session-local: persistent submission state needs the #332 my-submission
79+
// read (deferred until that backend endpoint is in the schema).
80+
const [submitted, setSubmitted] = useState(false);
81+
const [confirmWithdraw, setConfirmWithdraw] = useState(false);
82+
83+
useEffect(() => {
84+
if (submit.isCompleted) {
85+
toast.success('Work submitted and anchored on-chain.');
86+
setSubmitted(true);
87+
} else if (submit.isFailed) {
88+
toast.error(submit.error || 'Submission failed.');
89+
}
90+
}, [submit.isCompleted, submit.isFailed, submit.error]);
91+
92+
useEffect(() => {
93+
if (withdraw.isCompleted) {
94+
toast.success('Submission withdrawn.');
95+
setSubmitted(false);
96+
setConfirmWithdraw(false);
97+
} else if (withdraw.isFailed) {
98+
toast.error(withdraw.error || 'Withdraw failed.');
99+
}
100+
}, [withdraw.isCompleted, withdraw.isFailed, withdraw.error]);
101+
102+
if (!canSubmit(bounty, myApplication)) return null;
103+
104+
const urlValid = isUrl(contentUri.trim());
105+
106+
const handleSubmit = () => {
107+
if (!walletAddress) return toast.error('Connect a wallet to submit.');
108+
if (!urlValid) {
109+
setTouched(true);
110+
return;
111+
}
112+
void submit.run({
113+
applicantAddress: walletAddress,
114+
contentUri: contentUri.trim(),
115+
});
116+
};
117+
118+
const handleWithdraw = () => {
119+
if (!walletAddress) return toast.error('Connect a wallet to withdraw.');
120+
void withdraw.run({ applicantAddress: walletAddress });
121+
};
122+
123+
return (
124+
<div className='rounded-2xl border border-zinc-800 bg-zinc-900/40 p-5'>
125+
<h3 className='mb-1 flex items-center gap-2 text-sm font-semibold text-white'>
126+
<Send className='text-primary h-4 w-4' />
127+
Submit your work
128+
</h3>
129+
130+
{!walletAddress ? (
131+
<p className='mt-2 flex items-center gap-2 text-xs text-zinc-400'>
132+
<Wallet className='h-3.5 w-3.5' />
133+
Connect a wallet to submit.
134+
</p>
135+
) : submitted ? (
136+
// Submitted — show confirmation + withdraw.
137+
<div className='mt-3 space-y-3'>
138+
<div className='flex items-center gap-2 text-sm text-zinc-200'>
139+
<CheckCircle2 className='text-primary h-4 w-4' />
140+
Work submitted
141+
</div>
142+
{submit.txHash && (
143+
<a
144+
href={`https://stellar.expert/explorer/testnet/tx/${submit.txHash}`}
145+
target='_blank'
146+
rel='noreferrer'
147+
className='text-primary inline-flex items-center gap-1 text-xs hover:underline'
148+
>
149+
View transaction
150+
<ExternalLink className='h-3 w-3' />
151+
</a>
152+
)}
153+
{confirmWithdraw ? (
154+
<div className='flex gap-2'>
155+
<BoundlessButton
156+
variant='outline'
157+
className='flex-1'
158+
onClick={() => setConfirmWithdraw(false)}
159+
disabled={withdraw.isRunning}
160+
>
161+
Cancel
162+
</BoundlessButton>
163+
<BoundlessButton
164+
className='flex-1 bg-red-500 text-white hover:bg-red-600'
165+
onClick={handleWithdraw}
166+
disabled={withdraw.isRunning}
167+
>
168+
{withdraw.isRunning ? (
169+
<Loader2 className='h-4 w-4 animate-spin' />
170+
) : (
171+
'Confirm'
172+
)}
173+
</BoundlessButton>
174+
</div>
175+
) : (
176+
<BoundlessButton
177+
variant='outline'
178+
className='w-full text-red-400 hover:bg-red-500/10 hover:text-red-300'
179+
onClick={() => setConfirmWithdraw(true)}
180+
>
181+
Withdraw submission
182+
</BoundlessButton>
183+
)}
184+
</div>
185+
) : (
186+
// Submit form.
187+
<div className='mt-3 space-y-3'>
188+
<p className='text-xs text-zinc-500'>
189+
Link your deliverable — a pull request, repo, writeup, or demo.
190+
</p>
191+
<Input
192+
type='url'
193+
placeholder='https://github.com/org/repo/pull/123'
194+
value={contentUri}
195+
onChange={e => setContentUri(e.target.value)}
196+
disabled={submit.isRunning}
197+
className='h-10 border-zinc-800 bg-zinc-900/50 text-white placeholder:text-zinc-600'
198+
/>
199+
{touched && !urlValid && (
200+
<p className='text-xs text-red-500'>Enter a valid URL.</p>
201+
)}
202+
203+
{submit.isRunning ? (
204+
<div className='flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 text-xs text-zinc-300'>
205+
<Loader2 className='text-primary h-4 w-4 animate-spin' />
206+
{PHASE_LABEL[submit.phase] || 'Working…'}
207+
</div>
208+
) : (
209+
<BoundlessButton
210+
className='w-full'
211+
onClick={handleSubmit}
212+
disabled={!contentUri.trim()}
213+
>
214+
Submit work
215+
</BoundlessButton>
216+
)}
217+
</div>
218+
)}
219+
</div>
220+
);
221+
}

0 commit comments

Comments
 (0)