Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Lock,
Settings,
ShieldAlert,
SlidersHorizontal,
Trophy,
} from 'lucide-react';

Expand All @@ -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 {
Expand All @@ -30,6 +32,7 @@ const tabTriggerClassName =

const SETTINGS_SECTIONS = new Set([
'general',
'configuration',
'rewards',
'timeline',
'closeout',
Expand Down Expand Up @@ -118,6 +121,10 @@ function BountySettingsContent({
<Info className='h-4 w-4' />
General
</TabsTrigger>
<TabsTrigger value='configuration' className={tabTriggerClassName}>
<SlidersHorizontal className='h-4 w-4' />
Configuration
</TabsTrigger>
<TabsTrigger value='rewards' className={tabTriggerClassName}>
<Trophy className='h-4 w-4' />
Rewards
Expand All @@ -141,6 +148,11 @@ function BountySettingsContent({
/>
</TabsContent>

{/* Configuration — read-only mode + resources set at creation. */}
<TabsContent value='configuration' className='mt-0'>
<BountyConfigurationTab bountyId={bountyId} />
</TabsContent>

{/* Rewards — on-chain, immutable once funded. */}
<TabsContent value='rewards' className='mt-0'>
<div className='space-y-4'>
Expand Down
43 changes: 30 additions & 13 deletions components/bounties/detail/BountyEntryCta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down Expand Up @@ -239,17 +245,26 @@ export function BountyEntryCta({
</div>
) : (
<>
<BoundlessButton
size='lg'
className='w-full'
disabled={!accepting || deadlinePassed}
onClick={() => {
setEditing(false);
setDialogOpen(true);
}}
>
{primaryLabel}
</BoundlessButton>
{isOpenSingle && accepting && !deadlinePassed ? (
// Straight to the entry form: submitting is the claim.
<Link href={`/bounties/${bounty.id}/submit`} className='block'>
<BoundlessButton size='lg' className='w-full'>
{primaryLabel}
</BoundlessButton>
</Link>
) : (
<BoundlessButton
size='lg'
className='w-full'
disabled={!accepting || deadlinePassed}
onClick={() => {
setEditing(false);
setDialogOpen(true);
}}
>
{primaryLabel}
</BoundlessButton>
)}
<p className='mt-2 flex items-center justify-center gap-1.5 text-center text-xs text-zinc-500'>
{!accepting ? (
<>
Expand All @@ -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.'
)}
Expand Down
106 changes: 101 additions & 5 deletions components/bounties/detail/submit/BountySubmitForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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<string[]>([]);
const [uploading, setUploading] = useState(false);
const [mediaError, setMediaError] = useState<string | null>(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<EntryPayload | null>(null);

const {
register,
Expand All @@ -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);
Expand Down Expand Up @@ -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 (
<form onSubmit={handleSubmit(onSubmit)} className='w-full space-y-10'>
Expand All @@ -181,6 +250,33 @@ export default function BountySubmitForm({ bounty }: { bounty: BountyPublic }) {
</p>
</header>

{/* open single claim: submitting locks you in as the sole worker. */}
{needsClaim && (
<div className='space-y-3'>
<div className='border-primary/30 bg-primary/5 flex items-start gap-2.5 rounded-xl border p-3.5 text-sm text-zinc-300'>
<AlertTriangle className='text-primary mt-0.5 h-4 w-4 shrink-0' />
<span>
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.
</span>
</div>
{bounty.reputationMinimum != null && bounty.reputationMinimum > 0 && (
<div className='flex items-start gap-2.5 rounded-xl border border-zinc-800 bg-zinc-900/50 p-3.5 text-xs text-zinc-400'>
<AlertTriangle className='mt-0.5 h-4 w-4 shrink-0 text-amber-500' />
<span>
This bounty requires a minimum of{' '}
<span className='font-medium text-white'>
{bounty.reputationMinimum}
</span>{' '}
completed bounties. Your entry is rejected if you don&apos;t
meet it.
</span>
</div>
)}
</div>
)}

{/* Primary submission */}
<section className='space-y-2'>
<label className='text-sm font-medium text-zinc-200'>
Expand Down Expand Up @@ -321,7 +417,7 @@ export default function BountySubmitForm({ bounty }: { bounty: BountyPublic }) {
) : running ? (
<div className='flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 text-sm text-zinc-300'>
<Loader2 className='text-primary h-4 w-4 animate-spin' />
{PHASE_LABEL[submit.phase] || 'Working…'}
{phaseLabel}
</div>
) : (
<div className='flex items-center gap-3'>
Expand Down
10 changes: 10 additions & 0 deletions components/bounties/detail/submit/BountySubmitGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading