From df640c607a025cdb5b7466b07eb45aa0a45728a5 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Wed, 24 Jun 2026 23:34:48 +0100 Subject: [PATCH] feat(bounty): apply / join / claim flow + edit/withdraw (mode-aware) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components/bounties/detail/BountyEntryCta.tsx | 187 ++++++++++--- .../detail/entry/BountyApplicationForm.tsx | 210 +++++++++++++++ .../detail/entry/BountyEntryDialog.tsx | 255 ++++++++++++++++++ .../detail/entry/applicationSchema.ts | 182 +++++++++++++ 4 files changed, 794 insertions(+), 40 deletions(-) create mode 100644 components/bounties/detail/entry/BountyApplicationForm.tsx create mode 100644 components/bounties/detail/entry/BountyEntryDialog.tsx create mode 100644 components/bounties/detail/entry/applicationSchema.ts diff --git a/components/bounties/detail/BountyEntryCta.tsx b/components/bounties/detail/BountyEntryCta.tsx index 688a2bae..77004a06 100644 --- a/components/bounties/detail/BountyEntryCta.tsx +++ b/components/bounties/detail/BountyEntryCta.tsx @@ -1,33 +1,27 @@ 'use client'; +import { useEffect, useState } from 'react'; import { toast } from 'sonner'; -import { CheckCircle2, Lock } from 'lucide-react'; +import { CheckCircle2, Loader2, Lock } from 'lucide-react'; import { BoundlessButton } from '@/components/buttons'; import { Badge } from '@/components/ui/badge'; -import type { BountyPublic, MyBountyApplication } from '@/features/bounties'; +import { useWalletContext } from '@/components/providers/wallet-provider'; +import { + useLeaveCompetition, + useWithdrawApplication, + useWithdrawApplicationEscrow, + type BountyPublic, + type MyBountyApplication, +} from '@/features/bounties'; +import BountyEntryDialog from './entry/BountyEntryDialog'; -/** Statuses where a bounty still accepts new entries. */ const ACCEPTING_STATUSES = new Set([ 'open', 'open_for_applications', 'upcoming', ]); -type EntryKind = 'claim' | 'join' | 'apply' | 'view'; - -function entryKind(b: BountyPublic): { kind: EntryKind; label: string } { - const open = b.entryType === 'OPEN'; - const application = - b.entryType === 'APPLICATION_LIGHT' || b.entryType === 'APPLICATION_FULL'; - if (open && b.claimType === 'SINGLE_CLAIM') - return { kind: 'claim', label: 'Claim this bounty' }; - if (open && b.claimType === 'COMPETITION') - return { kind: 'join', label: 'Join competition' }; - if (application) return { kind: 'apply', label: 'Apply' }; - return { kind: 'view', label: 'View' }; -} - const STATUS_LABEL: Record = { SUBMITTED: 'Application submitted', SHORTLISTED: 'Shortlisted', @@ -51,27 +45,78 @@ export function BountyEntryCta({ bounty: BountyPublic; myApplication: MyBountyApplication | null; }) { - const { label } = entryKind(bounty); + const { walletAddress } = useWalletContext(); + const [dialogOpen, setDialogOpen] = useState(false); + const [editing, setEditing] = useState(false); + const [confirmWithdraw, setConfirmWithdraw] = useState(false); + + const withdrawApp = useWithdrawApplication(bounty.id); + const leave = useLeaveCompetition(bounty.id); + const withdrawClaim = useWithdrawApplicationEscrow(bounty.id); + + const isApplication = + bounty.entryType === 'APPLICATION_LIGHT' || + bounty.entryType === 'APPLICATION_FULL'; + const isCompetition = + bounty.entryType === 'OPEN' && bounty.claimType === 'COMPETITION'; + + const primaryLabel = isApplication + ? 'Apply' + : isCompetition + ? 'Join competition' + : 'Claim this bounty'; const deadlinePassed = !!bounty.applicationWindowCloseAt && new Date(bounty.applicationWindowCloseAt).getTime() < Date.now(); const accepting = ACCEPTING_STATUSES.has(bounty.status); - // An active (non-withdrawn) application means the caller is already in. - const activeStatus = - myApplication && myApplication.applicationStatus !== 'WITHDRAWN' - ? myApplication.applicationStatus - : null; + const withdrawn = + !!myApplication && + (myApplication.applicationStatus === 'WITHDRAWN' || + myApplication.status === 'withdrawn'); + const isActive = !!myApplication && !withdrawn; + // Application records are only mutable while SUBMITTED. + const editable = + isApplication && myApplication?.applicationStatus === 'SUBMITTED'; + + useEffect(() => { + if (withdrawClaim.isCompleted) toast.success('Claim withdrawn.'); + else if (withdrawClaim.isFailed) + toast.error(withdrawClaim.error || 'Withdraw failed.'); + }, [withdrawClaim.isCompleted, withdrawClaim.isFailed, withdrawClaim.error]); - // The real entry flow (forms / on-chain op) ships in #624; the CTA here is - // mode-aware and correctly gated, and hands off to that flow. - const onEntry = () => - toast.info('The entry flow is coming in the next step (apply/join/claim).'); + const handleWithdraw = () => { + if (isApplication) { + if (!myApplication) return; + withdrawApp.mutate(myApplication.id, { + onSuccess: () => toast.success('Application withdrawn.'), + onError: e => + toast.error((e as Error).message || 'Failed to withdraw.'), + }); + } else if (isCompetition) { + if (!walletAddress) return toast.error('Connect a wallet to leave.'); + leave.mutate( + { applicantAddress: walletAddress }, + { + onSuccess: () => toast.success('You left the competition.'), + onError: e => toast.error((e as Error).message || 'Failed to leave.'), + } + ); + } else { + if (!walletAddress) return toast.error('Connect a wallet to withdraw.'); + void withdrawClaim.run({ applicantAddress: walletAddress }); + } + setConfirmWithdraw(false); + }; + + const withdrawPending = + withdrawApp.isPending || leave.isPending || withdrawClaim.isRunning; + const withdrawLabel = isCompetition ? 'Leave competition' : 'Withdraw'; return (
- {activeStatus ? ( + {isActive ? (
@@ -79,15 +124,66 @@ export function BountyEntryCta({ You're in this bounty
- - {STATUS_LABEL[activeStatus] ?? activeStatus} - + {myApplication?.applicationStatus && ( + + {STATUS_LABEL[myApplication.applicationStatus] ?? + myApplication.applicationStatus} + + )} + + {/* Edit (application, while SUBMITTED) */} + {editable && ( + { + setEditing(true); + setDialogOpen(true); + }} + > + Edit application + + )} + + {/* Withdraw / leave (two-step confirm) */} + {(editable || isCompetition || !isApplication) && + (confirmWithdraw ? ( +
+ setConfirmWithdraw(false)} + disabled={withdrawPending} + > + Cancel + + + {withdrawPending ? ( + + ) : ( + 'Confirm' + )} + +
+ ) : ( + setConfirmWithdraw(true)} + > + {withdrawLabel} + + ))}
) : ( <> @@ -95,9 +191,12 @@ export function BountyEntryCta({ size='lg' className='w-full' disabled={!accepting || deadlinePassed} - onClick={onEntry} + onClick={() => { + setEditing(false); + setDialogOpen(true); + }} > - {label} + {primaryLabel}

{!accepting ? ( @@ -110,7 +209,7 @@ export function BountyEntryCta({ Applications have closed. - ) : myApplication?.applicationStatus === 'WITHDRAWN' ? ( + ) : withdrawn ? ( 'You withdrew earlier — you can enter again.' ) : ( 'Funds are held in escrow and paid on-chain to winners.' @@ -118,6 +217,14 @@ export function BountyEntryCta({

)} + +
); } diff --git a/components/bounties/detail/entry/BountyApplicationForm.tsx b/components/bounties/detail/entry/BountyApplicationForm.tsx new file mode 100644 index 00000000..fa93749a --- /dev/null +++ b/components/bounties/detail/entry/BountyApplicationForm.tsx @@ -0,0 +1,210 @@ +'use client'; + +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Loader2 } from 'lucide-react'; + +import { BoundlessButton } from '@/components/buttons'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { + ApplicationEntryType, + ApplicationFormData, + MAX_LINKS, + countWords, + emptyApplicationForm, + makeApplicationSchema, +} from './applicationSchema'; + +interface BountyApplicationFormProps { + entryType: ApplicationEntryType; + initialData?: Partial; + submitLabel: string; + isSubmitting?: boolean; + onSubmit: (data: ApplicationFormData) => void; + onCancel?: () => void; +} + +export default function BountyApplicationForm({ + entryType, + initialData, + submitLabel, + isSubmitting = false, + onSubmit, + onCancel, +}: BountyApplicationFormProps) { + const isFull = entryType === 'APPLICATION_FULL'; + const form = useForm({ + resolver: zodResolver(makeApplicationSchema(entryType)), + defaultValues: { ...emptyApplicationForm, ...initialData }, + }); + + const proposalField = isFull ? 'proposalFull' : 'proposalShort'; + const words = countWords(form.watch(proposalField) ?? ''); + + return ( +
+ + {/* Proposal */} + ( + + + {isFull ? 'Full proposal' : 'Proposal'} + * + + +