diff --git a/components/bounties/detail/BountyDetail.tsx b/components/bounties/detail/BountyDetail.tsx index 4da15dd8..23fb8142 100644 --- a/components/bounties/detail/BountyDetail.tsx +++ b/components/bounties/detail/BountyDetail.tsx @@ -17,6 +17,7 @@ import EmptyState from '@/components/EmptyState'; import { computeBountyModeLabel } from '@/components/organization/bounties/new/tabs/schemas/modeSchema'; import { useBounty, useMyBountyApplication } from '@/features/bounties'; import { BountyEntryCta } from './BountyEntryCta'; +import BountySubmitPanel from './submit/BountySubmitPanel'; // Markdown renderer (matches the wizard's editor package). const Markdown = dynamic( @@ -204,6 +205,12 @@ export default function BountyDetail({ id }: { id: string }) { bounty={bounty} myApplication={myApplication ?? null} /> + + {/* Submit work (shown when the builder is eligible) */} + diff --git a/components/bounties/detail/submit/BountySubmitPanel.tsx b/components/bounties/detail/submit/BountySubmitPanel.tsx new file mode 100644 index 00000000..3a93e5cc --- /dev/null +++ b/components/bounties/detail/submit/BountySubmitPanel.tsx @@ -0,0 +1,221 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { + CheckCircle2, + ExternalLink, + Loader2, + Send, + Wallet, +} from 'lucide-react'; + +import { BoundlessButton } from '@/components/buttons'; +import { Input } from '@/components/ui/input'; +import { useWalletContext } from '@/components/providers/wallet-provider'; +import { + useSubmitBounty, + useWithdrawSubmission, + type BountyPublic, + type MyBountyApplication, + type EscrowRunPhase, +} from '@/features/bounties'; + +/** Statuses where a builder is entitled to submit work. */ +function canSubmit( + bounty: BountyPublic, + app: MyBountyApplication | null +): boolean { + if (!app) return false; + const withdrawn = + app.applicationStatus === 'WITHDRAWN' || app.status === 'withdrawn'; + if (withdrawn) return false; + const isApplication = + bounty.entryType === 'APPLICATION_LIGHT' || + bounty.entryType === 'APPLICATION_FULL'; + if (isApplication) { + return ( + app.applicationStatus === 'SELECTED' || + app.applicationStatus === 'SHORTLISTED' + ); + } + // Open single claim / competition: claimed or joined (escrow active). + return app.status === 'active' || app.applicationStatus === 'SELECTED'; +} + +const PHASE_LABEL: Record = { + idle: '', + starting: 'Preparing submission…', + signing: 'Signing…', + submitting: 'Submitting…', + polling: 'Anchoring on-chain…', + completed: 'Confirmed', + failed: 'Failed', +}; + +const isUrl = (s: string): boolean => { + try { + new URL(s); + return true; + } catch { + return false; + } +}; + +export default function BountySubmitPanel({ + bounty, + myApplication, +}: { + bounty: BountyPublic; + myApplication: MyBountyApplication | null; +}) { + const { walletAddress } = useWalletContext(); + const submit = useSubmitBounty(bounty.id); + const withdraw = useWithdrawSubmission(bounty.id); + + const [contentUri, setContentUri] = useState(''); + const [touched, setTouched] = useState(false); + // Session-local: persistent submission state needs the #332 my-submission + // read (deferred until that backend endpoint is in the schema). + const [submitted, setSubmitted] = useState(false); + const [confirmWithdraw, setConfirmWithdraw] = useState(false); + + useEffect(() => { + if (submit.isCompleted) { + toast.success('Work submitted and anchored on-chain.'); + setSubmitted(true); + } else if (submit.isFailed) { + toast.error(submit.error || 'Submission failed.'); + } + }, [submit.isCompleted, submit.isFailed, submit.error]); + + useEffect(() => { + if (withdraw.isCompleted) { + toast.success('Submission withdrawn.'); + setSubmitted(false); + setConfirmWithdraw(false); + } else if (withdraw.isFailed) { + toast.error(withdraw.error || 'Withdraw failed.'); + } + }, [withdraw.isCompleted, withdraw.isFailed, withdraw.error]); + + if (!canSubmit(bounty, myApplication)) return null; + + const urlValid = isUrl(contentUri.trim()); + + const handleSubmit = () => { + if (!walletAddress) return toast.error('Connect a wallet to submit.'); + if (!urlValid) { + setTouched(true); + return; + } + void submit.run({ + applicantAddress: walletAddress, + contentUri: contentUri.trim(), + }); + }; + + const handleWithdraw = () => { + if (!walletAddress) return toast.error('Connect a wallet to withdraw.'); + void withdraw.run({ applicantAddress: walletAddress }); + }; + + return ( +
+

+ + Submit your work +

+ + {!walletAddress ? ( +

+ + Connect a wallet to submit. +

+ ) : submitted ? ( + // Submitted — show confirmation + withdraw. +
+
+ + Work submitted +
+ {submit.txHash && ( + + View transaction + + + )} + {confirmWithdraw ? ( +
+ setConfirmWithdraw(false)} + disabled={withdraw.isRunning} + > + Cancel + + + {withdraw.isRunning ? ( + + ) : ( + 'Confirm' + )} + +
+ ) : ( + setConfirmWithdraw(true)} + > + Withdraw submission + + )} +
+ ) : ( + // Submit form. +
+

+ Link your deliverable — a pull request, repo, writeup, or demo. +

+ setContentUri(e.target.value)} + disabled={submit.isRunning} + className='h-10 border-zinc-800 bg-zinc-900/50 text-white placeholder:text-zinc-600' + /> + {touched && !urlValid && ( +

Enter a valid URL.

+ )} + + {submit.isRunning ? ( +
+ + {PHASE_LABEL[submit.phase] || 'Working…'} +
+ ) : ( + + Submit work + + )} +
+ )} +
+ ); +}