From 98b403b5974e23926784a8ca499b6a3421124ffc Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Thu, 9 Jul 2026 21:42:45 +0100 Subject: [PATCH 1/2] feat(bounty): surface full config in settings, edit country and github URL Add a read-only Configuration tab to the bounty settings page showing the mode (entry and claim rules) and organizer resources, so the on-chain choices made at creation are visible. Make the off-chain country and GitHub issue URL editable in the General tab, and expose country, githubIssueUrl and resources on the bounty detail type. --- .../bounties/[bountyId]/settings/page.tsx | 12 ++ .../settings/BountyConfigurationTab.tsx | 192 ++++++++++++++++++ .../settings/BountyGeneralSettingsTab.tsx | 29 +++ lib/api/generated/schema.d.ts | 6 + 4 files changed, 239 insertions(+) create mode 100644 components/organization/bounties/settings/BountyConfigurationTab.tsx 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/organization/bounties/settings/BountyConfigurationTab.tsx b/components/organization/bounties/settings/BountyConfigurationTab.tsx new file mode 100644 index 00000000..a85a6070 --- /dev/null +++ b/components/organization/bounties/settings/BountyConfigurationTab.tsx @@ -0,0 +1,192 @@ +'use client'; + +import { Loader2, Lock } from 'lucide-react'; + +import { useBounty, type BountyPublic } from '@/features/bounties'; +import { + computeBountyModeLabel, + type BountyClaimType, + type BountyEntryType, +} from '@/components/organization/bounties/new/tabs/schemas/modeSchema'; + +/** Read-only plain-language descriptions of the entry / claim axes. */ +const ENTRY_INFO: Record = { + OPEN: { label: 'Open', hint: 'Anyone can start working right away.' }, + APPLICATION_LIGHT: { + label: 'Application (light)', + hint: 'A short application before work begins.', + }, + APPLICATION_FULL: { + label: 'Application (full)', + hint: 'A full application and review before work begins.', + }, +}; + +const CLAIM_INFO: Record = { + SINGLE_CLAIM: { + label: 'Single claim', + hint: 'One contributor works exclusively and is paid.', + }, + COMPETITION: { + label: 'Competition', + hint: 'Several work in parallel; the best win.', + }, +}; + +/** + * Read-only view of how the bounty was set up: its mode (entry x claim) and the + * organizer-supplied resources. These choices are anchored at publish and can + * never be changed here, so they are shown for context but never editable. The + * editable off-chain fields live in the General tab. + */ +export default function BountyConfigurationTab({ + bountyId, +}: { + bountyId: string; +}) { + const { data: bounty, isLoading } = useBounty(bountyId); + + if (isLoading || !bounty) { + return ( +
+ +
+ ); + } + + return ; +} + +function ConfigurationView({ bounty }: { bounty: BountyPublic }) { + const entryInfo = bounty.entryType + ? ENTRY_INFO[bounty.entryType as BountyEntryType] + : undefined; + const claimInfo = bounty.claimType + ? CLAIM_INFO[bounty.claimType as BountyClaimType] + : undefined; + const resources = bounty.resources ?? []; + + return ( +
+
+ + + How this bounty was set up. These choices are fixed once the bounty is + published and cannot be changed here. + +
+ + {/* ── Mode ── */} +
+ + {entryInfo && ( + + )} + {claimInfo && ( + + )} +
+ + {/* ── Resources ── */} +
+ {resources.length === 0 ? ( +

No resources were added.

+ ) : ( +
    + {resources.map((resource, i) => { + const href = resource.link || resource.file?.url; + const label = + resource.description || + resource.file?.name || + resource.link || + 'Resource'; + return ( +
  • + {href ? ( + + {label} + + ) : ( + {label} + )} +
  • + ); + })} +
+ )} +
+
+ ); +} + +function Section({ + title, + description, + children, +}: { + title: string; + description: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+

{description}

+
+
{children}
+
+ ); +} + +function ReadField({ + label, + value, + hint, +}: { + label: string; + value: React.ReactNode; + hint?: string; +}) { + return ( +
+

{label}

+
+ {value} + {hint &&

{hint}

} +
+
+ ); +} diff --git a/components/organization/bounties/settings/BountyGeneralSettingsTab.tsx b/components/organization/bounties/settings/BountyGeneralSettingsTab.tsx index d9faef9c..ba3391bc 100644 --- a/components/organization/bounties/settings/BountyGeneralSettingsTab.tsx +++ b/components/organization/bounties/settings/BountyGeneralSettingsTab.tsx @@ -39,6 +39,13 @@ const generalSchema = z.object({ .min(1, 'Description is required') .max(5000, 'Description must be 5000 characters or fewer'), category: z.enum(BOUNTY_CATEGORIES), + country: z.string().trim().max(100).optional(), + githubIssueUrl: z + .string() + .trim() + .url('Enter a valid URL') + .or(z.literal('')) + .optional(), submissionVisibility: z.enum(['ORGANIZER_ONLY', 'HIDDEN_UNTIL_DEADLINE']), documentation: z.boolean(), tweet: z.boolean(), @@ -145,6 +152,8 @@ function GeneralForm({ category: BOUNTY_CATEGORIES.includes(bounty.category as BountyCategory) ? (bounty.category as BountyCategory) : 'DEVELOPMENT', + country: bounty.country ?? '', + githubIssueUrl: bounty.githubIssueUrl ?? '', submissionVisibility: bounty.submissionVisibility, documentation: bounty.submissionRequirements.documentation, tweet: bounty.submissionRequirements.tweet, @@ -163,6 +172,10 @@ function GeneralForm({ title: values.title, description: values.description, category: values.category, + country: values.country?.trim() ? values.country.trim() : null, + githubIssueUrl: values.githubIssueUrl?.trim() + ? values.githubIssueUrl.trim() + : null, submissionVisibility: values.submissionVisibility, submissionRequirements: { documentation: values.documentation, @@ -244,6 +257,22 @@ function GeneralForm({ )} /> + + + + + + + + {/* ── 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. From a6f04a842cc36621a2cf8438715819a8e17b5345 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Thu, 9 Jul 2026 21:42:55 +0100 Subject: [PATCH 2/2] feat(bounty): open single-claim submit-entry flow, first entry locks in For OPEN + SINGLE_CLAIM, drop the separate claim step. The detail-page CTA now reads Submit entry and goes straight to the entry form, where submitting runs the on-chain apply then submit as one action. The first valid entry locks the bounty to that builder; the submit gate admits a first entrant while nobody else holds the claim, and the form shows the lock-in and reputation notices. --- components/bounties/detail/BountyEntryCta.tsx | 43 ++++--- .../detail/submit/BountySubmitForm.tsx | 106 +++++++++++++++++- .../detail/submit/BountySubmitGate.tsx | 10 ++ 3 files changed, 141 insertions(+), 18 deletions(-) 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 */}