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
476 changes: 357 additions & 119 deletions app/(landing)/organizations/[id]/bounties/page.tsx

Large diffs are not rendered by default.

72 changes: 72 additions & 0 deletions components/organization/bounties/DeleteBountyDraftDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
'use client';

import { AlertTriangle } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';

interface DeleteBountyDraftDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
draftTitle: string;
onConfirm: () => void;
isDeleting?: boolean;
}

export default function DeleteBountyDraftDialog({
open,
onOpenChange,
draftTitle,
onConfirm,
isDeleting = false,
}: DeleteBountyDraftDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className='border-zinc-800 bg-zinc-950'>
<AlertDialogHeader className='items-center'>
<div className='flex size-12 items-center justify-center rounded-full bg-red-500/10'>
<AlertTriangle className='size-6 text-red-500' />
</div>
<AlertDialogTitle className='text-xl text-white'>
Delete this draft?
</AlertDialogTitle>
<AlertDialogDescription className='text-center text-zinc-400'>
{draftTitle ? (
<>
<span className='font-semibold text-white'>
&quot;{draftTitle}&quot;
</span>{' '}
will be permanently deleted. This cannot be undone.
</>
) : (
'This draft will be permanently deleted. This cannot be undone.'
)}
</AlertDialogDescription>
</AlertDialogHeader>

<AlertDialogFooter>
<AlertDialogCancel
disabled={isDeleting}
className='border-zinc-700 bg-zinc-900 text-zinc-300 hover:bg-zinc-800 hover:text-white'
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
disabled={isDeleting}
className='bg-red-500 text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50'
>
{isDeleting ? 'Deleting...' : 'Delete draft'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
64 changes: 61 additions & 3 deletions components/organization/bounties/new/NewBountyTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import { Tabs, TabsContent } from '@/components/ui/tabs';
import { useBountySteps } from '@/hooks/use-bounty-steps';
import { useBountyDraft } from '@/hooks/use-bounty-draft';
import { useBountyPublish } from '@/hooks/use-bounty-publish';
import { buildMockBountyData } from './mock-data';
import ScopeTab from './tabs/ScopeTab';
import ModeTab from './tabs/ModeTab';
import SubmissionModelTab from './tabs/SubmissionModelTab';
import RewardTab from './tabs/RewardTab';
import ResourcesTab from './tabs/ResourcesTab';
import ReviewTab from './tabs/ReviewTab';
import type { ModeSelection } from './tabs/schemas/modeSchema';
import {
Expand Down Expand Up @@ -75,6 +77,7 @@ export default function NewBountyTab({
isSavingDraft,
saveDraft,
saveStep,
saveAllSections,
} = useBountyDraft({
organizationId: derivedOrgId,
initialDraftId: resolvedInitialDraftId,
Expand Down Expand Up @@ -125,6 +128,7 @@ export default function NewBountyTab({
mode: false,
submission: false,
reward: false,
resources: false,
review: false,
});

Expand Down Expand Up @@ -158,13 +162,40 @@ export default function NewBountyTab({
// Publish via the shared escrow runner. Defaults to MANAGED (the connected
// custodial wallet funds + the backend signs); the funding-source picker
// (external wallet / treasury) is a follow-up.
const { publish, isPublishing } = useBountyPublish({
const { publish, isPublishing, publishResponse } = useBountyPublish({
organizationId: derivedOrgId || '',
stepData,
draftId,
fundingMode: 'MANAGED',
});

// Once the publish finalizes on-chain, leave the wizard for the organizer's
// bounty list rather than lingering on the (now read-only) review.
const hasRedirectedRef = useRef(false);
useEffect(() => {
if (publishResponse && derivedOrgId && !hasRedirectedRef.current) {
hasRedirectedRef.current = true;
router.replace(`/organizations/${derivedOrgId}/bounties`);
}
}, [publishResponse, derivedOrgId, router]);

// Dev-only convenience: fill every section with valid mock data, persist it
// in one PATCH, and jump to Review. Never rendered in production builds.
const isDev = process.env.NODE_ENV === 'development';
const [isFillingMock, setIsFillingMock] = useState(false);
const handleFillMock = useCallback(async () => {
setIsFillingMock(true);
try {
await saveAllSections(buildMockBountyData());
navigateToStep('review');
toast.success('Filled the wizard with mock data');
} catch {
toast.error('Failed to fill mock data');
} finally {
setIsFillingMock(false);
}
}, [saveAllSections, navigateToStep]);

if (isLoadingDraft) {
return (
<div className='bg-background-main-bg flex min-h-[60vh] flex-1 items-center justify-center text-white'>
Expand All @@ -189,6 +220,18 @@ export default function NewBountyTab({
className='bg-background-main-bg mx-auto max-w-6xl flex-1 overflow-hidden px-6 py-8 text-white'
id={organizationId}
>
{isDev && (
<div className='flex justify-end px-6 md:px-20'>
<button
type='button'
onClick={handleFillMock}
disabled={isFillingMock}
className='flex items-center gap-2 rounded-md border border-dashed border-amber-500/50 bg-amber-500/10 px-3 py-1.5 text-xs font-medium text-amber-300 transition-colors hover:bg-amber-500/20 disabled:opacity-50'
>
{isFillingMock ? 'Filling...' : '⚡ Fill with mock (dev)'}
</button>
</div>
)}
<Tabs value={activeTab} className='w-full'>
<div className='px-6 py-6 md:px-20'>
<TabsContent value='scope' className='mt-0'>
Expand All @@ -204,6 +247,7 @@ export default function NewBountyTab({
<ModeTab
onSave={createSaveHandler('mode', 'submission')}
onContinue={() => navigateToStep('submission')}
onBack={() => navigateToStep('scope')}
initialData={stepData.mode}
isLoading={loadingStates.mode}
/>
Expand All @@ -212,8 +256,10 @@ export default function NewBountyTab({
<TabsContent value='submission' className='mt-0'>
<SubmissionModelTab
mode={modeSelection}
category={stepData.scope?.category}
onSave={createSaveHandler('submission', 'reward')}
onContinue={() => navigateToStep('reward')}
onBack={() => navigateToStep('mode')}
initialData={stepData.submission}
isLoading={loadingStates.submission}
/>
Expand All @@ -229,17 +275,29 @@ export default function NewBountyTab({
}
: undefined
}
onSave={createSaveHandler('reward', 'review')}
onContinue={() => navigateToStep('review')}
onSave={createSaveHandler('reward', 'resources')}
onContinue={() => navigateToStep('resources')}
onBack={() => navigateToStep('submission')}
initialData={stepData.reward}
isLoading={loadingStates.reward}
/>
</TabsContent>

<TabsContent value='resources' className='mt-0'>
<ResourcesTab
onSave={createSaveHandler('resources', 'review')}
onContinue={() => navigateToStep('review')}
onBack={() => navigateToStep('reward')}
initialData={stepData.resources}
isLoading={loadingStates.resources}
/>
</TabsContent>

<TabsContent value='review' className='mt-0'>
<ReviewTab
allData={stepData}
onEdit={navigateToStep}
onBack={() => navigateToStep('resources')}
onSaveDraft={saveDraft}
isSavingDraft={isSavingDraft}
onPublish={async () => {
Expand Down
17 changes: 15 additions & 2 deletions components/organization/bounties/new/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,32 @@ import type { ModeFormData } from './tabs/schemas/modeSchema';
import type { SubmissionModelFormData } from './tabs/schemas/submissionModelSchema';
import type { ScopeFormData } from './tabs/schemas/scopeSchema';
import type { RewardFormData } from './tabs/schemas/rewardSchema';
import type { ResourcesFormData } from './tabs/schemas/resourcesSchema';

export type StepStatus = 'pending' | 'active' | 'completed';

export type StepKey = 'scope' | 'mode' | 'submission' | 'reward' | 'review';
export type StepKey =
| 'scope'
| 'mode'
| 'submission'
| 'reward'
| 'resources'
| 'review';

export interface StepData {
status: StepStatus;
isCompleted: boolean;
data?: Record<string, unknown>;
}

/** The five wizard steps, in order. `review` carries no editable section. */
/** The wizard steps, in order. `resources` is optional; `review` carries no
* editable section. */
export const STEP_ORDER: StepKey[] = [
'scope',
'mode',
'submission',
'reward',
'resources',
'review',
];

Expand All @@ -31,6 +40,7 @@ export interface BountyFormData {
mode?: ModeFormData;
submission?: SubmissionModelFormData;
reward?: RewardFormData;
resources?: ResourcesFormData;
}

/**
Expand Down Expand Up @@ -60,6 +70,9 @@ export const isBountyStepDataValid = (
reward?.rewardCurrency && (reward?.prizeTiers?.length ?? 0) > 0
);
}
case 'resources':
// Optional step: never blocks the resume from advancing to review.
return true;
case 'review':
return false;
default:
Expand Down
66 changes: 66 additions & 0 deletions components/organization/bounties/new/mock-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { BountyFormData } from './constants';

/** A `datetime-local` string (YYYY-MM-DDTHH:mm) `days` from now. */
function futureLocal(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
d.setSeconds(0, 0);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(
d.getDate()
)}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}

/**
* A complete, publish-valid bounty configuration for the dev-only "Fill with
* mock" button. Uses the simplest valid mode (open, single claim) so every
* section passes its schema and the publish gate. Development category, so the
* GitHub issue URL is present; reputation matches the development floor.
*/
export function buildMockBountyData(): BountyFormData {
return {
scope: {
title: 'Build a Soroban faucet bot',
description:
'## Overview\n\nMock bounty generated for local testing.\n\n' +
'### What needs to be built\n\n' +
'- A Telegram bot that drips testnet XLM\n' +
'- Rate limiting per user\n' +
'- A simple `/faucet` command\n\n' +
'**Done looks like:** a deployed bot with a short README.',
category: 'DEVELOPMENT',
country: 'United States',
githubIssueUrl: 'https://github.com/boundlessfi/boundless/issues/1',
projectId: null,
bountyWindowId: null,
},
mode: {
entryType: 'OPEN',
claimType: 'SINGLE_CLAIM',
winnerCount: 1,
},
submission: {
submissionDeadline: futureLocal(30),
submissionVisibility: 'ORGANIZER_ONLY',
reputationMinimum: 50,
applicationWindowCloseAt: null,
maxApplicants: null,
shortlistSize: null,
applicationCreditCost: null,
},
reward: {
rewardCurrency: 'USDC',
prizeTiers: [{ position: 1, amount: '100', passMark: null }],
},
resources: {
resources: [
{
id: 'resource-mock-1',
link: 'https://developers.stellar.org/docs',
description: 'Stellar developer documentation',
file: undefined,
},
],
},
};
}
19 changes: 18 additions & 1 deletion components/organization/bounties/new/tabs/ModeTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
Loader2,
Minus,
Plus,
ArrowLeft,
} from 'lucide-react';

import { BoundlessButton } from '@/components/buttons';
Expand All @@ -28,6 +29,7 @@ import {

interface ModeTabProps {
onContinue?: () => void;
onBack?: () => void;
onSave?: (data: ModeFormData) => Promise<void>;
initialData?: Partial<ModeFormData>;
isLoading?: boolean;
Expand Down Expand Up @@ -87,6 +89,7 @@ const defaultValues: ModeFormData = {

export default function ModeTab({
onContinue,
onBack,
onSave,
initialData,
isLoading = false,
Expand Down Expand Up @@ -314,7 +317,21 @@ export default function ModeTab({
</p>
</div>

<div className='flex justify-end pt-4'>
<div className='flex items-center justify-between pt-4'>
{onBack ? (
<BoundlessButton
type='button'
variant='outline'
size='lg'
onClick={onBack}
disabled={isLoading}
>
<ArrowLeft className='mr-2 h-4 w-4' />
Back
</BoundlessButton>
) : (
<span />
)}
<BoundlessButton
type='submit'
size='lg'
Expand Down
Loading