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 (
+ {/* 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 */}
@@ -321,7 +417,7 @@ export default function BountySubmitForm({ bounty }: { bounty: BountyPublic }) {
) : running ? (
- {PHASE_LABEL[submit.phase] || 'Working…'}
+ {phaseLabel}
) : (
diff --git a/components/bounties/detail/submit/BountySubmitGate.tsx b/components/bounties/detail/submit/BountySubmitGate.tsx
index 48a4708c..824c2e18 100644
--- a/components/bounties/detail/submit/BountySubmitGate.tsx
+++ b/components/bounties/detail/submit/BountySubmitGate.tsx
@@ -17,6 +17,16 @@ function canSubmit(
bounty: BountyPublic,
app: MyBountyApplication | null
): boolean {
+ // open single claim: submitting an entry IS the claim, so the first eligible
+ // entrant can reach the form without a prior claim. Allow whenever nobody
+ // else already holds the bounty (or the caller is the active claimant).
+ const isOpenSingle =
+ bounty.entryType === 'OPEN' && bounty.claimType === 'SINGLE_CLAIM';
+ if (isOpenSingle) {
+ if (app?.status === 'active') return true;
+ return !bounty.claimedBy;
+ }
+
if (!app) return false;
const withdrawn =
app.applicationStatus === 'WITHDRAWN' || app.status === 'withdrawn';
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.