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 (
+
+
+ );
+}
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 (
+
+
+ );
+}
diff --git a/components/organization/bounties/new/tabs/schemas/rewardSchema.ts b/components/organization/bounties/new/tabs/schemas/rewardSchema.ts
new file mode 100644
index 00000000..cfa30b59
--- /dev/null
+++ b/components/organization/bounties/new/tabs/schemas/rewardSchema.ts
@@ -0,0 +1,70 @@
+import { z } from 'zod';
+
+import { MAX_PRIZE_TIERS, type BountyClaimType } from './modeSchema';
+
+/** A single prize position. `amount` is a positive decimal string (token units). */
+export const prizeTierSchema = z.object({
+ position: z.number().int().min(1, 'Position must be >= 1'),
+ amount: z
+ .string()
+ .regex(/^\d+(?:\.\d+)?$/, 'Amount must be a positive decimal')
+ .refine(s => Number(s) > 0, 'Amount must be greater than 0'),
+ passMark: z.union([z.number().int().min(0).max(100), z.null()]).optional(),
+});
+
+const rewardBase = z.object({
+ rewardCurrency: z.string().trim().min(1, 'Reward currency is required'),
+ prizeTiers: z.array(prizeTierSchema).min(1, 'Add at least one prize tier'),
+});
+
+export type RewardFormData = z.input;
+
+/** Shared default-export schema for the type only; use makeRewardSchema to validate. */
+export const rewardSchema = rewardBase;
+
+/**
+ * Mode-aware reward schema. A single-claim bounty pays exactly one winner; a
+ * competition pays 1 to {MAX_PRIZE_TIERS}. Positions must be unique and include
+ * position 1, and every amount must be > 0 — matching the publish gate
+ * (`deriveWinnerDistribution` in the backend).
+ */
+export function makeRewardSchema(claimType: BountyClaimType) {
+ return rewardBase.superRefine((data, ctx) => {
+ const tiers = data.prizeTiers ?? [];
+ const count = tiers.length;
+
+ if (claimType === 'SINGLE_CLAIM' && count !== 1) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['prizeTiers'],
+ message: 'A single-claim bounty has exactly one prize tier',
+ });
+ }
+ if (claimType === 'COMPETITION' && (count < 1 || count > MAX_PRIZE_TIERS)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['prizeTiers'],
+ message: `A competition has 1 to ${MAX_PRIZE_TIERS} prize tiers`,
+ });
+ }
+
+ const positions = new Set();
+ tiers.forEach((tier, index) => {
+ if (positions.has(tier.position)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['prizeTiers', index, 'position'],
+ message: `Duplicate position ${tier.position}`,
+ });
+ }
+ positions.add(tier.position);
+ });
+ if (count > 0 && !positions.has(1)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['prizeTiers'],
+ message: 'A prize tier at position 1 is required',
+ });
+ }
+ });
+}
diff --git a/components/organization/bounties/new/tabs/schemas/scopeSchema.ts b/components/organization/bounties/new/tabs/schemas/scopeSchema.ts
new file mode 100644
index 00000000..ac707618
--- /dev/null
+++ b/components/organization/bounties/new/tabs/schemas/scopeSchema.ts
@@ -0,0 +1,31 @@
+import { z } from 'zod';
+
+/**
+ * Scope step: the bounty's identity. Mirrors the backend BountyScopeSection
+ * (title / description / optional GitHub issue / optional project + window).
+ * The reward currency lives in the Reward step alongside the prize tiers it
+ * denominates (that is the section the backend persists it under).
+ */
+export const scopeSchema = z.object({
+ title: z
+ .string()
+ .trim()
+ .min(1, 'Title is required')
+ .max(200, 'Title must be 200 characters or fewer'),
+ description: z
+ .string()
+ .trim()
+ .min(1, 'Description is required')
+ .max(5000, 'Description must be 5000 characters or fewer'),
+ githubIssueUrl: z
+ .union([z.string().url('Enter a valid URL'), z.literal(''), z.null()])
+ .optional()
+ .transform(v => (v === '' ? null : v)),
+ githubIssueNumber: z
+ .union([z.number().int().positive(), z.null()])
+ .optional(),
+ projectId: z.union([z.string(), z.null()]).optional(),
+ bountyWindowId: z.union([z.string(), z.null()]).optional(),
+});
+
+export type ScopeFormData = z.input;
diff --git a/lib/utils/bounty-escrow.ts b/lib/utils/bounty-escrow.ts
new file mode 100644
index 00000000..52cbc063
--- /dev/null
+++ b/lib/utils/bounty-escrow.ts
@@ -0,0 +1,58 @@
+import type { RewardsFormData } from '@/components/organization/hackathons/new/tabs/schemas/rewardsSchema';
+import type { RewardFormData } from '@/components/organization/bounties/new/tabs/schemas/rewardSchema';
+import type { BountyWinnerDistributionEntry } from '@/features/bounties';
+import {
+ PLATFORM_FEE,
+ buildWinnerDistribution,
+ calculatePlatformFeeAmount,
+ calculateTotalPrizeAmount,
+ getTotalPrizePoolForFunding,
+} from './hackathon-escrow';
+
+export { PLATFORM_FEE };
+
+/**
+ * Adapt the bounty reward shape (`{ position, amount }`) to the hackathon
+ * RewardsFormData the prize-pool helpers expect (`{ place, prizeAmount, rank,
+ * kind: 'OVERALL' }`), so the configure + publish flows reuse one source of
+ * prize-pool / fee / winner-distribution math (lib/utils/hackathon-escrow.ts)
+ * instead of duplicating it.
+ */
+function toRewards(reward: RewardFormData | undefined): RewardsFormData {
+ const tiers = reward?.prizeTiers ?? [];
+ return {
+ prizeTiers: tiers.map(tier => ({
+ id: String(tier.position),
+ place: String(tier.position),
+ prizeAmount: tier.amount,
+ rank: tier.position,
+ passMark: tier.passMark ?? 0,
+ kind: 'OVERALL' as const,
+ })),
+ };
+}
+
+/** Sum of the prize tiers (token-native units), excluding the platform fee. */
+export const getBountyPrizePool = (
+ reward: RewardFormData | undefined
+): number => calculateTotalPrizeAmount(toRewards(reward));
+
+/** The 2.5% platform fee charged on top of the prize pool at publish. */
+export const getBountyPlatformFee = (
+ reward: RewardFormData | undefined
+): number => calculatePlatformFeeAmount(getBountyPrizePool(reward));
+
+/** Total the funder is debited: prize pool + platform fee. */
+export const getBountyTotalFunding = (
+ reward: RewardFormData | undefined
+): number => getTotalPrizePoolForFunding(toRewards(reward));
+
+/**
+ * On-chain winner distribution ([{position, percent}] summing to 100) derived
+ * from the prize tiers. Used by the publish flow (#601); returns undefined when
+ * there are no fundable tiers (the backend then defaults to 100% @ position 1).
+ */
+export const buildBountyWinnerDistribution = (
+ reward: RewardFormData | undefined
+): BountyWinnerDistributionEntry[] | undefined =>
+ buildWinnerDistribution(toRewards(reward));