diff --git a/components/organization/bounties/new/NewBountyTab.tsx b/components/organization/bounties/new/NewBountyTab.tsx index 127efde9..d8b5105e 100644 --- a/components/organization/bounties/new/NewBountyTab.tsx +++ b/components/organization/bounties/new/NewBountyTab.tsx @@ -5,11 +5,13 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; import { Tabs, TabsContent } from '@/components/ui/tabs'; -import { BoundlessButton } from '@/components/buttons'; import { useBountySteps } from '@/hooks/use-bounty-steps'; import { useBountyDraft } from '@/hooks/use-bounty-draft'; +import ScopeTab from './tabs/ScopeTab'; import ModeTab from './tabs/ModeTab'; import SubmissionModelTab from './tabs/SubmissionModelTab'; +import RewardTab from './tabs/RewardTab'; +import ReviewTab from './tabs/ReviewTab'; import type { ModeSelection } from './tabs/schemas/modeSchema'; import { BountyFormData, @@ -24,33 +26,6 @@ interface NewBountyTabProps { draftId?: string; } -/** Placeholder for a wizard section whose tab lands in #600 (Scope / Reward). */ -function SectionPlaceholder({ - title, - description, - onContinue, -}: { - title: string; - description: string; - onContinue?: () => void; -}) { - return ( -
-
-

{title}

-

{description}

-
- {onContinue && ( -
- - Continue - -
- )} -
- ); -} - /** * Bounty Configure wizard orchestrator. Wires the step navigation (URL-driven), * the draft state (lazy create + per-section PATCH + resume), and the section @@ -206,11 +181,11 @@ export default function NewBountyTab({
- {/* TODO(#600): replace with ScopeTab (title/description/github/...). */} - navigateToStep('mode')} + initialData={stepData.scope} + isLoading={loadingStates.scope} /> @@ -234,41 +209,35 @@ export default function NewBountyTab({ - {/* TODO(#600): replace with RewardTab (currency + prize tiers). */} - navigateToStep('review')} + initialData={stepData.reward} + isLoading={loadingStates.reward} /> - {/* TODO(#600/#601): replace with ReviewTab + publish/funding flow. */} -
-
-

- Review & publish -

-

- The review summary and the publish + funding flow land in #600 - / #601. -

-
-
- - {isSavingDraft ? 'Saving...' : 'Save draft'} - - - Publish (coming in #601) - -
-
+ { + toast.info('Publishing lands in #601 (escrow publish flow).'); + }} + />
diff --git a/components/organization/bounties/new/constants.ts b/components/organization/bounties/new/constants.ts index 3135094e..2ef8220e 100644 --- a/components/organization/bounties/new/constants.ts +++ b/components/organization/bounties/new/constants.ts @@ -1,9 +1,7 @@ import type { ModeFormData } from './tabs/schemas/modeSchema'; import type { SubmissionModelFormData } from './tabs/schemas/submissionModelSchema'; -import type { - BountyRewardSection, - BountyScopeSection, -} from '@/features/bounties'; +import type { ScopeFormData } from './tabs/schemas/scopeSchema'; +import type { RewardFormData } from './tabs/schemas/rewardSchema'; export type StepStatus = 'pending' | 'active' | 'completed'; @@ -25,15 +23,14 @@ export const STEP_ORDER: StepKey[] = [ ]; /** - * In-progress wizard form snapshot. The four editable sections use the #599 - * form schemas where they exist (mode, submission) and the generated section - * types otherwise (scope, reward — refined by the dedicated tabs in #600). + * In-progress wizard form snapshot, one entry per editable section, each typed + * by its tab's form schema. */ export interface BountyFormData { - scope?: BountyScopeSection; + scope?: ScopeFormData; mode?: ModeFormData; submission?: SubmissionModelFormData; - reward?: BountyRewardSection; + reward?: RewardFormData; } /** diff --git a/components/organization/bounties/new/tabs/ReviewTab.tsx b/components/organization/bounties/new/tabs/ReviewTab.tsx new file mode 100644 index 00000000..d1c55a42 --- /dev/null +++ b/components/organization/bounties/new/tabs/ReviewTab.tsx @@ -0,0 +1,295 @@ +import React from 'react'; +import { toast } from 'sonner'; +import { AlertTriangle, CheckCircle2, Loader2, Pencil } from 'lucide-react'; + +import { BoundlessButton } from '@/components/buttons'; +import { + PLATFORM_FEE, + getBountyPlatformFee, + getBountyPrizePool, + getBountyTotalFunding, +} from '@/lib/utils/bounty-escrow'; +import type { BountyFormData, StepKey } from '../constants'; +import { computeBountyModeLabel } from './schemas/modeSchema'; +import { scopeSchema } from './schemas/scopeSchema'; +import { modeSchema } from './schemas/modeSchema'; +import { makeSubmissionModelSchema } from './schemas/submissionModelSchema'; +import { makeRewardSchema } from './schemas/rewardSchema'; + +interface ReviewTabProps { + allData: BountyFormData; + onEdit?: (step: StepKey) => void; + onPublish?: () => Promise | void; + onSaveDraft?: () => Promise; + isLoading?: boolean; + isSavingDraft?: boolean; +} + +interface SectionIssues { + step: StepKey; + label: string; + valid: boolean; +} + +/** + * Validate every section against its schema (the same per-mode rules the + * publish gate enforces). Returns per-section validity so the review step can + * summarize what's left and block publish until all four pass. + */ +function validateAll(data: BountyFormData): { + isValid: boolean; + sections: SectionIssues[]; +} { + const sections: SectionIssues[] = []; + + sections.push({ + step: 'scope', + label: 'Scope', + valid: scopeSchema.safeParse(data.scope).success, + }); + + const modeOk = modeSchema.safeParse(data.mode).success; + sections.push({ step: 'mode', label: 'Mode', valid: modeOk }); + + sections.push({ + step: 'submission', + label: 'Submission model', + valid: + modeOk && data.mode + ? makeSubmissionModelSchema( + data.mode.entryType, + data.mode.claimType + ).safeParse(data.submission).success + : false, + }); + + sections.push({ + step: 'reward', + label: 'Reward', + valid: + modeOk && data.mode + ? makeRewardSchema(data.mode.claimType).safeParse(data.reward).success + : false, + }); + + return { isValid: sections.every(s => s.valid), sections }; +} + +function Row({ + label, + value, +}: { + label: string; + value?: string | number | null; +}) { + if (value === undefined || value === null || value === '') return null; + return ( +
+ {label} + {value} +
+ ); +} + +function SummaryCard({ + title, + step, + onEdit, + children, +}: { + title: string; + step: StepKey; + onEdit?: (step: StepKey) => void; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {onEdit && ( + + )} +
+
{children}
+
+ ); +} + +export default function ReviewTab({ + allData, + onEdit, + onPublish, + onSaveDraft, + isLoading = false, + isSavingDraft = false, +}: ReviewTabProps) { + const { isValid, sections } = validateAll(allData); + const currency = allData.reward?.rewardCurrency || 'USDC'; + const prizePool = getBountyPrizePool(allData.reward); + const platformFee = getBountyPlatformFee(allData.reward); + const totalFunding = getBountyTotalFunding(allData.reward); + + const modeLabel = allData.mode + ? computeBountyModeLabel( + allData.mode.entryType, + allData.mode.claimType, + allData.mode.winnerCount ?? 1 + ) + : undefined; + + const handlePublish = async () => { + if (!onPublish) return; + try { + await onPublish(); + } catch { + // The publish flow surfaces its own errors. + } + }; + + const handleSaveDraft = async () => { + if (!onSaveDraft) return; + try { + await onSaveDraft(); + } catch { + toast.error('Failed to save draft. Please try again.'); + } + }; + + return ( +
+ {/* Validation summary */} +
+
+ {isValid ? ( + + ) : ( + + )} +

+ {isValid + ? 'Everything looks good — ready to publish' + : 'Some sections still need attention'} +

+
+
+ {sections.map(s => ( + + ))} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + {(allData.reward?.prizeTiers ?? []).map((tier, i) => ( + + ))} +
+ + + +
+
+ + {/* Publish CTA — disabled until every section is valid. */} +
+ {onSaveDraft && ( + + {isSavingDraft ? 'Saving...' : 'Save draft'} + + )} + + {isLoading ? ( + <> + + Publishing... + + ) : ( + 'Publish' + )} + +
+
+ ); +} diff --git a/components/organization/bounties/new/tabs/RewardTab.tsx b/components/organization/bounties/new/tabs/RewardTab.tsx new file mode 100644 index 00000000..1f82efbe --- /dev/null +++ b/components/organization/bounties/new/tabs/RewardTab.tsx @@ -0,0 +1,227 @@ +import React from 'react'; +import { useForm, useFieldArray } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { toast } from 'sonner'; +import { Loader2 } from 'lucide-react'; + +import { BoundlessButton } from '@/components/buttons'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { + PLATFORM_FEE, + getBountyPlatformFee, + getBountyPrizePool, + getBountyTotalFunding, +} from '@/lib/utils/bounty-escrow'; +import { MAX_PRIZE_TIERS, type BountyClaimType } from './schemas/modeSchema'; +import { makeRewardSchema, type RewardFormData } from './schemas/rewardSchema'; + +interface RewardTabProps { + /** The claim type + winner count chosen in ModeTab; drives the tier count. */ + mode?: { claimType: BountyClaimType; winnerCount?: number }; + onContinue?: () => void; + onSave?: (data: RewardFormData) => Promise; + initialData?: Partial; + isLoading?: boolean; +} + +const ordinal = (n: number): string => { + const s = ['th', 'st', 'nd', 'rd']; + const v = n % 100; + return `${n}${s[(v - 20) % 10] ?? s[v] ?? s[0]}`; +}; + +export default function RewardTab({ + mode, + onContinue, + onSave, + initialData, + isLoading = false, +}: RewardTabProps) { + const claimType: BountyClaimType = mode?.claimType ?? 'SINGLE_CLAIM'; + const targetCount = + claimType === 'SINGLE_CLAIM' + ? 1 + : Math.min(Math.max(mode?.winnerCount ?? 1, 1), MAX_PRIZE_TIERS); + + const form = useForm({ + resolver: zodResolver(makeRewardSchema(claimType)), + defaultValues: { + rewardCurrency: initialData?.rewardCurrency ?? 'USDC', + prizeTiers: + initialData?.prizeTiers && initialData.prizeTiers.length > 0 + ? initialData.prizeTiers + : [{ position: 1, amount: '', passMark: null }], + }, + }); + + const { fields, replace } = useFieldArray({ + control: form.control, + name: 'prizeTiers', + }); + + // Keep the tier rows in sync with the mode's winner count, preserving any + // amounts already entered. + React.useEffect(() => { + const current = form.getValues('prizeTiers') ?? []; + if (current.length === targetCount) return; + const next = Array.from({ length: targetCount }, (_, i) => ({ + position: i + 1, + amount: current[i]?.amount ?? '', + passMark: current[i]?.passMark ?? null, + })); + replace(next); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [targetCount]); + + const watched = form.watch(); + const prizePool = getBountyPrizePool(watched); + const platformFee = getBountyPlatformFee(watched); + const totalFunding = getBountyTotalFunding(watched); + const currency = watched.rewardCurrency || 'USDC'; + + const onSubmit = async (data: RewardFormData) => { + try { + if (onSave) await onSave(data); + if (onContinue) onContinue(); + } catch (error: unknown) { + const err = error as { + response?: { data?: { message?: string | string[] } }; + message?: string; + }; + const message = err.response?.data?.message || err.message; + const errorMessage = Array.isArray(message) ? message[0] : message; + toast.error(errorMessage || 'Failed to save reward. Please try again.'); + } + }; + + return ( +
+ + ( + + + Reward currency* + + + + + + + )} + /> + +
+
+

+ Prize tiers* +

+

+ {claimType === 'SINGLE_CLAIM' + ? 'A single-claim bounty pays one winner.' + : `This competition pays ${targetCount} winner${targetCount > 1 ? 's' : ''}. Adjust the winner count in the Mode step.`} +

+
+ +
+ {fields.map((fieldRow, index) => ( + ( + +
+
+ {ordinal(index + 1)} place +
+ +
+ field.onChange(e.target.value)} + /> + + {currency} + +
+
+
+ +
+ )} + /> + ))} +
+ {form.formState.errors.prizeTiers?.message && ( +

+ {form.formState.errors.prizeTiers.message} +

+ )} +
+ + {/* Funding preview */} +
+
+ Prize pool + + {prizePool.toLocaleString()} {currency} + +
+
+ + Platform fee ({PLATFORM_FEE}%) + + + {platformFee.toLocaleString()} {currency} + +
+
+ Total to fund + + {totalFunding.toLocaleString()} {currency} + +
+
+ +
+ + {isLoading ? ( + <> + + Saving... + + ) : ( + 'Continue' + )} + +
+ + + ); +} diff --git a/components/organization/bounties/new/tabs/ScopeTab.tsx b/components/organization/bounties/new/tabs/ScopeTab.tsx new file mode 100644 index 00000000..ad4fc71c --- /dev/null +++ b/components/organization/bounties/new/tabs/ScopeTab.tsx @@ -0,0 +1,182 @@ +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { toast } from 'sonner'; +import { Loader2 } from 'lucide-react'; + +import { BoundlessButton } from '@/components/buttons'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { scopeSchema, type ScopeFormData } from './schemas/scopeSchema'; + +interface ScopeTabProps { + onContinue?: () => void; + onSave?: (data: ScopeFormData) => Promise; + initialData?: Partial; + isLoading?: boolean; +} + +const defaultValues: ScopeFormData = { + title: '', + description: '', + githubIssueUrl: null, + githubIssueNumber: null, +}; + +export default function ScopeTab({ + onContinue, + onSave, + initialData, + isLoading = false, +}: ScopeTabProps) { + const form = useForm({ + resolver: zodResolver(scopeSchema), + defaultValues: { ...defaultValues, ...initialData }, + }); + + React.useEffect(() => { + if (initialData) form.reset({ ...defaultValues, ...initialData }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialData]); + + const onSubmit = async (data: ScopeFormData) => { + try { + if (onSave) await onSave(data); + if (onContinue) onContinue(); + } catch (error: unknown) { + const err = error as { + response?: { data?: { message?: string | string[] } }; + message?: string; + }; + const message = err.response?.data?.message || err.message; + const errorMessage = Array.isArray(message) ? message[0] : message; + toast.error(errorMessage || 'Failed to save scope. Please try again.'); + } + }; + + return ( +
+ + ( + + + Title* + + + + + + + )} + /> + + ( + + + Description* + + +