From 044feb6e08380c95ab9856e79b626b5bdb6933b8 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Wed, 24 Jun 2026 09:49:18 +0100 Subject: [PATCH 1/2] feat(bounty): wizard enhancements (resources step, currency, reputation, nav) - Resources: new optional Resources step (links + PDF/DOC/PPT/MD uploads), mirroring the hackathon resources tab; never blocks publish. Reuses a now configurable ResourceFileUpload (folder/tags/accepted types props; unique input id per row). - Reward currency: dropdown of USDC (default) / XLM (disabled) with real token logos, replacing the free-text field. - Reputation: per-category minimum floor (development highest); the organizer can raise it but not drop below the category baseline. - Navigation: Back button on every step after the first. - Scope: category chips + searchable country dropdown with flags + markdown description; GitHub issue URL required only for development. - Publish: redirect to the organizer's bounty list once publish finalizes. - Dev-only "Fill with mock" button to populate every section at once. --- .../bounties/new/NewBountyTab.tsx | 64 +++- .../organization/bounties/new/constants.ts | 17 +- .../organization/bounties/new/mock-data.ts | 66 ++++ .../bounties/new/tabs/ModeTab.tsx | 19 +- .../bounties/new/tabs/ResourcesTab.tsx | 316 ++++++++++++++++++ .../bounties/new/tabs/ReviewTab.tsx | 98 ++++-- .../bounties/new/tabs/RewardTab.tsx | 71 +++- .../bounties/new/tabs/ScopeTab.tsx | 283 +++++++++++++--- .../bounties/new/tabs/SubmissionModelTab.tsx | 57 +++- .../new/tabs/schemas/resourcesSchema.ts | 42 +++ .../bounties/new/tabs/schemas/scopeSchema.ts | 91 +++-- .../new/tabs/schemas/submissionModelSchema.ts | 28 +- .../tabs/components/ResourceFileUpload.tsx | 56 +++- features/bounties/types.ts | 3 +- hooks/use-bounty-draft.ts | 56 +++- hooks/use-bounty-steps.ts | 1 + 16 files changed, 1124 insertions(+), 144 deletions(-) create mode 100644 components/organization/bounties/new/mock-data.ts create mode 100644 components/organization/bounties/new/tabs/ResourcesTab.tsx create mode 100644 components/organization/bounties/new/tabs/schemas/resourcesSchema.ts diff --git a/components/organization/bounties/new/NewBountyTab.tsx b/components/organization/bounties/new/NewBountyTab.tsx index d0429a635..362b860b6 100644 --- a/components/organization/bounties/new/NewBountyTab.tsx +++ b/components/organization/bounties/new/NewBountyTab.tsx @@ -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 { @@ -75,6 +77,7 @@ export default function NewBountyTab({ isSavingDraft, saveDraft, saveStep, + saveAllSections, } = useBountyDraft({ organizationId: derivedOrgId, initialDraftId: resolvedInitialDraftId, @@ -125,6 +128,7 @@ export default function NewBountyTab({ mode: false, submission: false, reward: false, + resources: false, review: false, }); @@ -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 (
@@ -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 && ( +
+ +
+ )}
@@ -204,6 +247,7 @@ export default function NewBountyTab({ navigateToStep('submission')} + onBack={() => navigateToStep('scope')} initialData={stepData.mode} isLoading={loadingStates.mode} /> @@ -212,8 +256,10 @@ export default function NewBountyTab({ navigateToStep('reward')} + onBack={() => navigateToStep('mode')} initialData={stepData.submission} isLoading={loadingStates.submission} /> @@ -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} /> + + navigateToStep('review')} + onBack={() => navigateToStep('reward')} + initialData={stepData.resources} + isLoading={loadingStates.resources} + /> + + navigateToStep('resources')} onSaveDraft={saveDraft} isSavingDraft={isSavingDraft} onPublish={async () => { diff --git a/components/organization/bounties/new/constants.ts b/components/organization/bounties/new/constants.ts index 2ef8220e2..fff92bbdf 100644 --- a/components/organization/bounties/new/constants.ts +++ b/components/organization/bounties/new/constants.ts @@ -2,10 +2,17 @@ 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; @@ -13,12 +20,14 @@ export interface StepData { data?: Record; } -/** 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', ]; @@ -31,6 +40,7 @@ export interface BountyFormData { mode?: ModeFormData; submission?: SubmissionModelFormData; reward?: RewardFormData; + resources?: ResourcesFormData; } /** @@ -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: diff --git a/components/organization/bounties/new/mock-data.ts b/components/organization/bounties/new/mock-data.ts new file mode 100644 index 000000000..14100f068 --- /dev/null +++ b/components/organization/bounties/new/mock-data.ts @@ -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, + }, + ], + }, + }; +} diff --git a/components/organization/bounties/new/tabs/ModeTab.tsx b/components/organization/bounties/new/tabs/ModeTab.tsx index f0f96ebe6..f25e7ef47 100644 --- a/components/organization/bounties/new/tabs/ModeTab.tsx +++ b/components/organization/bounties/new/tabs/ModeTab.tsx @@ -11,6 +11,7 @@ import { Loader2, Minus, Plus, + ArrowLeft, } from 'lucide-react'; import { BoundlessButton } from '@/components/buttons'; @@ -28,6 +29,7 @@ import { interface ModeTabProps { onContinue?: () => void; + onBack?: () => void; onSave?: (data: ModeFormData) => Promise; initialData?: Partial; isLoading?: boolean; @@ -87,6 +89,7 @@ const defaultValues: ModeFormData = { export default function ModeTab({ onContinue, + onBack, onSave, initialData, isLoading = false, @@ -314,7 +317,21 @@ export default function ModeTab({

-
+
+ {onBack ? ( + + + Back + + ) : ( + + )} void; + onBack?: () => void; + onSave?: (data: ResourcesFormData) => Promise; + initialData?: ResourcesFormData; + isLoading?: boolean; +} + +interface ResourceRowProps { + index: number; + onRemove: (index: number) => void; + control: Control; +} + +const ResourceRow = ({ index, onRemove, control }: ResourceRowProps) => { + return ( +
+
+
+ +
+
+ {/* Link */} + ( + + + Link (optional) + + +
+ + +
+
+ +
+ )} + /> + + {/* Description */} + ( + + + Description (optional) + + +