diff --git a/app/(landing)/organizations/[id]/bounties/[bountyId]/settings/page.tsx b/app/(landing)/organizations/[id]/bounties/[bountyId]/settings/page.tsx index 74b259b0..401ae865 100644 --- a/app/(landing)/organizations/[id]/bounties/[bountyId]/settings/page.tsx +++ b/app/(landing)/organizations/[id]/bounties/[bountyId]/settings/page.tsx @@ -9,6 +9,7 @@ import { Lock, Settings, ShieldAlert, + SlidersHorizontal, Trophy, } from 'lucide-react'; @@ -17,6 +18,7 @@ import { AuthGuard } from '@/components/auth'; import Loading from '@/components/Loading'; import EmptyState from '@/components/EmptyState'; import BountyGeneralSettingsTab from '@/components/organization/bounties/settings/BountyGeneralSettingsTab'; +import BountyConfigurationTab from '@/components/organization/bounties/settings/BountyConfigurationTab'; import BountyTimelineSettingsTab from '@/components/organization/bounties/settings/BountyTimelineSettingsTab'; import BountySettingsPanel from '@/components/organization/bounties/manage/BountySettingsPanel'; import { @@ -30,6 +32,7 @@ const tabTriggerClassName = const SETTINGS_SECTIONS = new Set([ 'general', + 'configuration', 'rewards', 'timeline', 'closeout', @@ -118,6 +121,10 @@ function BountySettingsContent({ General + + + Configuration + Rewards @@ -141,6 +148,11 @@ function BountySettingsContent({ /> + {/* Configuration — read-only mode + resources set at creation. */} + + + + {/* Rewards — on-chain, immutable once funded. */}
diff --git a/components/bounties/detail/BountyEntryCta.tsx b/components/bounties/detail/BountyEntryCta.tsx index 02128f81..63fa3be2 100644 --- a/components/bounties/detail/BountyEntryCta.tsx +++ b/components/bounties/detail/BountyEntryCta.tsx @@ -64,12 +64,18 @@ export function BountyEntryCta({ bounty.entryType === 'APPLICATION_FULL'; const isCompetition = bounty.entryType === 'OPEN' && bounty.claimType === 'COMPETITION'; + // open single claim: there is no separate claim step. The CTA sends the + // builder straight to the entry form, where submitting locks them in. + const isOpenSingle = + bounty.entryType === 'OPEN' && bounty.claimType === 'SINGLE_CLAIM'; const primaryLabel = isApplication ? 'Apply' : isCompetition ? 'Join competition' - : 'Claim this bounty'; + : isOpenSingle + ? 'Submit entry' + : 'Claim this bounty'; const deadlinePassed = !!bounty.applicationWindowCloseAt && @@ -239,17 +245,26 @@ export function BountyEntryCta({
) : ( <> - { - setEditing(false); - setDialogOpen(true); - }} - > - {primaryLabel} - + {isOpenSingle && accepting && !deadlinePassed ? ( + // Straight to the entry form: submitting is the claim. + + + {primaryLabel} + + + ) : ( + { + setEditing(false); + setDialogOpen(true); + }} + > + {primaryLabel} + + )}

{!accepting ? ( <> @@ -262,7 +277,9 @@ export function BountyEntryCta({ Applications have closed. ) : withdrawn ? ( - 'You withdrew earlier — you can enter again.' + 'You withdrew earlier, you can enter again.' + ) : isOpenSingle ? ( + 'The first valid entry locks in this bounty. Funds are paid on-chain.' ) : ( 'Funds are held in escrow and paid on-chain to winners.' )} diff --git a/components/bounties/detail/submit/BountySubmitForm.tsx b/components/bounties/detail/submit/BountySubmitForm.tsx index e1665d85..25cf0dcc 100644 --- a/components/bounties/detail/submit/BountySubmitForm.tsx +++ b/components/bounties/detail/submit/BountySubmitForm.tsx @@ -7,7 +7,9 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { toast } from 'sonner'; +import { useQueryClient } from '@tanstack/react-query'; import { + AlertTriangle, FileText, Github, Loader2, @@ -23,6 +25,9 @@ import { Input } from '@/components/ui/input'; import { uploadService } from '@/lib/api/upload'; import { useWalletContext } from '@/components/providers/wallet-provider'; import { + bountyKeys, + useApplyToBountyEscrow, + useMyBountyApplication, useSubmitBounty, ESCROW_PHASE_LABEL, type BountyPublic, @@ -34,6 +39,15 @@ const PHASE_LABEL = { polling: 'Anchoring on-chain…', }; +/** The submit-work payload, minus the wallet address which is added at run. */ +type EntryPayload = { + contentUri: string; + documentationUrl?: string; + tweetUrl?: string; + demoVideoUrl?: string; + mediaUrls?: string[]; +}; + type Requirements = BountyPublic['submissionRequirements']; function makeSchema(req: Requirements) { @@ -87,13 +101,27 @@ function LinkField({ export default function BountySubmitForm({ bounty }: { bounty: BountyPublic }) { const router = useRouter(); + const queryClient = useQueryClient(); const { walletAddress } = useWalletContext(); const submit = useSubmitBounty(bounty.id); + const claim = useApplyToBountyEscrow(bounty.id); + const { data: myApplication } = useMyBountyApplication(bounty.id); const req = bounty.submissionRequirements; + // open single claim: submitting an entry IS the claim. The first eligible + // entrant is locked in. If the caller is not yet the active claimant, the + // "Submit entry" action first runs the on-chain apply, then the submit. + const isOpenSingle = + bounty.entryType === 'OPEN' && bounty.claimType === 'SINGLE_CLAIM'; + const alreadyClaimed = myApplication?.status === 'active'; + const needsClaim = isOpenSingle && !alreadyClaimed; + const [mediaUrls, setMediaUrls] = useState([]); const [uploading, setUploading] = useState(false); const [mediaError, setMediaError] = useState(null); + // Set when a claim must land before the submit fires (the apply -> submit + // chain). Cleared once the submit is dispatched or the claim fails. + const [pendingEntry, setPendingEntry] = useState(null); const { register, @@ -119,6 +147,36 @@ export default function BountySubmitForm({ bounty }: { bounty: BountyPublic }) { } }, [submit.isCompleted, submit.isFailed, submit.error, bounty.id, router]); + // Second half of the apply -> submit chain: once the on-chain claim lands, + // refresh the application state and fire the queued submit. If the claim + // fails (e.g. someone else got in first), surface it and drop the queue. + useEffect(() => { + if (!pendingEntry) return; + if (claim.isCompleted) { + void queryClient.invalidateQueries({ + queryKey: bountyKeys.myApplication(bounty.id), + }); + if (walletAddress) { + void submit.run({ applicantAddress: walletAddress, ...pendingEntry }); + } + setPendingEntry(null); + } else if (claim.isFailed) { + toast.error(claim.error || 'Could not lock in your entry.'); + setPendingEntry(null); + } + // submit is intentionally omitted: run() is stable and we only want this to + // fire on a claim state transition, not on submit's own updates. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + claim.isCompleted, + claim.isFailed, + claim.error, + pendingEntry, + walletAddress, + bounty.id, + queryClient, + ]); + const handleFiles = async (files: FileList | null) => { if (!files?.length) return; setMediaError(null); @@ -155,17 +213,28 @@ export default function BountySubmitForm({ bounty }: { bounty: BountyPublic }) { setMediaError('At least one image is required.'); return; } - void submit.run({ - applicantAddress: walletAddress, + const entry: EntryPayload = { contentUri: data.contentUri.trim(), documentationUrl: data.documentationUrl.trim() || undefined, tweetUrl: data.tweetUrl.trim() || undefined, demoVideoUrl: data.demoVideoUrl.trim() || undefined, mediaUrls: mediaUrls.length ? mediaUrls : undefined, - }); + }; + // open single claim first-entry: lock in the claim on-chain, then submit + // (the queued effect above fires the submit when the claim completes). + if (needsClaim) { + setPendingEntry(entry); + void claim.run({ applicantAddress: walletAddress }); + return; + } + void submit.run({ applicantAddress: walletAddress, ...entry }); }; - const running = submit.isRunning; + const claiming = claim.isRunning || !!pendingEntry; + const running = claiming || submit.isRunning; + const phaseLabel = claiming + ? 'Locking in your entry…' + : PHASE_LABEL[submit.phase] || 'Working…'; return (

@@ -181,6 +250,33 @@ export default function BountySubmitForm({ bounty }: { bounty: BountyPublic }) {

+ {/* open single claim: submitting locks you in as the sole worker. */} + {needsClaim && ( +
+
+ + + Submitting locks this bounty to you as the sole worker. The first + valid entry wins, so make sure your work is ready before you + submit. + +
+ {bounty.reputationMinimum != null && bounty.reputationMinimum > 0 && ( +
+ + + This bounty requires a minimum of{' '} + + {bounty.reputationMinimum} + {' '} + completed bounties. Your entry is rejected if you don't + meet it. + +
+ )} +
+ )} + {/* Primary submission */}
{/* ── Submissions & applications ── */} diff --git a/lib/api/generated/schema.d.ts b/lib/api/generated/schema.d.ts index c76b095b..f49020a9 100644 --- a/lib/api/generated/schema.d.ts +++ b/lib/api/generated/schema.d.ts @@ -23200,6 +23200,12 @@ export interface components { escrowTxHash?: string | null; /** @description Bounty discipline (DESIGN, DEVELOPMENT, CONTENT, GROWTH, COMMUNITY). */ category?: string | null; + /** @description GitHub issue URL (required for development bounties). */ + githubIssueUrl?: string | null; + /** @description Country/region the bounty targets, if any. */ + country?: string | null; + /** @description Organizer-supplied resources (links and files) shared with contributors. */ + resources?: components['schemas']['BountyResourceItemDto'][] | null; /** * Format: date-time * @description Work/submission deadline (set at publish); null while in draft.