diff --git a/components/organization/bounties/new/NewBountyTab.tsx b/components/organization/bounties/new/NewBountyTab.tsx
new file mode 100644
index 00000000..127efde9
--- /dev/null
+++ b/components/organization/bounties/new/NewBountyTab.tsx
@@ -0,0 +1,277 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+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 ModeTab from './tabs/ModeTab';
+import SubmissionModelTab from './tabs/SubmissionModelTab';
+import type { ModeSelection } from './tabs/schemas/modeSchema';
+import {
+ BountyFormData,
+ STEP_ORDER,
+ isBountyStepDataValid,
+ type StepData,
+ type StepKey,
+} from './constants';
+
+interface NewBountyTabProps {
+ organizationId?: string;
+ 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
+ * tabs. The ModeTab feeds the chosen mode into the SubmissionModelTab so its
+ * conditional fields render correctly.
+ *
+ * Scope / Reward / Review tabs arrive in #600 and the publish + funding flow in
+ * #601; their wiring seams (saveSection, draftId, navigateToStep) are in place
+ * here. There are intentionally no AI entry points.
+ */
+export default function NewBountyTab({
+ organizationId,
+ draftId: initialDraftId,
+}: NewBountyTabProps) {
+ const derivedOrgId = useMemo(() => {
+ if (organizationId) return organizationId;
+ if (typeof window !== 'undefined') {
+ const parts = window.location.pathname.split('/');
+ if (parts.length >= 3 && parts[1] === 'organizations') return parts[2];
+ }
+ return undefined;
+ }, [organizationId]);
+
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ // draftId arrives via the route path (/drafts/[draftId]) or, on /new, via a
+ // ?draftId= we add after the first save. Either resumes the same draft.
+ const resolvedInitialDraftId =
+ initialDraftId ?? searchParams.get('draftId') ?? undefined;
+
+ const { activeTab, navigateToStep, setStepsFromDraft, updateStepCompletion } =
+ useBountySteps('scope');
+
+ const onDraftLoadedRef = useRef<
+ ((formData: BountyFormData, firstIncompleteStep: StepKey) => void) | null
+ >(null);
+
+ const {
+ draftId,
+ stepData,
+ setStepData,
+ isLoadingDraft,
+ currentError,
+ isSavingDraft,
+ saveDraft,
+ saveStep,
+ } = useBountyDraft({
+ organizationId: derivedOrgId,
+ initialDraftId: resolvedInitialDraftId,
+ onDraftLoaded: (formData, firstIncompleteStep) => {
+ onDraftLoadedRef.current?.(formData, firstIncompleteStep);
+ },
+ });
+
+ // Persist a freshly-created draft id into the URL so a refresh resumes it.
+ useEffect(() => {
+ if (!draftId || initialDraftId) return;
+ if (searchParams.get('draftId') === draftId) return;
+ const params = new URLSearchParams(searchParams.toString());
+ params.set('draftId', draftId);
+ router.replace(`${pathname}?${params.toString()}`, { scroll: false });
+ }, [draftId, initialDraftId, searchParams, pathname, router]);
+
+ const onDraftLoaded = useCallback(
+ (formData: BountyFormData, firstIncompleteStep: StepKey) => {
+ setStepData(formData);
+ const activeIndex = STEP_ORDER.indexOf(firstIncompleteStep);
+ const newSteps = {} as Record;
+ STEP_ORDER.forEach((key, index) => {
+ const isCompleted = isBountyStepDataValid(key, formData);
+ if (index < activeIndex) {
+ newSteps[key] = { status: 'completed', isCompleted: true };
+ } else if (index === activeIndex) {
+ newSteps[key] = { status: 'active', isCompleted };
+ } else {
+ newSteps[key] = {
+ status: 'pending',
+ isCompleted: key === 'review' ? false : isCompleted,
+ };
+ }
+ });
+ setStepsFromDraft(newSteps, firstIncompleteStep);
+ },
+ [setStepData, setStepsFromDraft]
+ );
+
+ useEffect(() => {
+ onDraftLoadedRef.current = onDraftLoaded;
+ }, [onDraftLoaded]);
+
+ // Per-step save: persist the section, mark it complete, and advance.
+ const [loadingStates, setLoadingStates] = useState>({
+ scope: false,
+ mode: false,
+ submission: false,
+ reward: false,
+ review: false,
+ });
+
+ const createSaveHandler = useCallback(
+ (stepKey: StepKey, nextStep: StepKey) =>
+ async (data: T) => {
+ if (!derivedOrgId) {
+ toast.error('Organization ID is required');
+ return;
+ }
+ setLoadingStates(prev => ({ ...prev, [stepKey]: true }));
+ try {
+ await saveStep(
+ stepKey,
+ data as NonNullable
+ );
+ updateStepCompletion(stepKey, true, nextStep);
+ } catch {
+ throw new Error(`Failed to save ${stepKey} step`);
+ } finally {
+ setLoadingStates(prev => ({ ...prev, [stepKey]: false }));
+ }
+ },
+ [derivedOrgId, saveStep, updateStepCompletion]
+ );
+
+ const modeSelection: ModeSelection | undefined = stepData.mode
+ ? { entryType: stepData.mode.entryType, claimType: stepData.mode.claimType }
+ : undefined;
+
+ if (isLoadingDraft) {
+ return (
+
+ );
+ }
+
+ if (currentError) {
+ return (
+
+ {currentError}
+
+ );
+ }
+
+ return (
+
+
+
+
+ {/* TODO(#600): replace with ScopeTab (title/description/github/...). */}
+ navigateToStep('mode')}
+ />
+
+
+
+ navigateToStep('submission')}
+ initialData={stepData.mode}
+ isLoading={loadingStates.mode}
+ />
+
+
+
+ navigateToStep('reward')}
+ initialData={stepData.submission}
+ isLoading={loadingStates.submission}
+ />
+
+
+
+ {/* TODO(#600): replace with RewardTab (currency + prize tiers). */}
+ navigateToStep('review')}
+ />
+
+
+
+ {/* 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)
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/organization/bounties/new/constants.ts b/components/organization/bounties/new/constants.ts
new file mode 100644
index 00000000..3135094e
--- /dev/null
+++ b/components/organization/bounties/new/constants.ts
@@ -0,0 +1,71 @@
+import type { ModeFormData } from './tabs/schemas/modeSchema';
+import type { SubmissionModelFormData } from './tabs/schemas/submissionModelSchema';
+import type {
+ BountyRewardSection,
+ BountyScopeSection,
+} from '@/features/bounties';
+
+export type StepStatus = 'pending' | 'active' | 'completed';
+
+export type StepKey = 'scope' | 'mode' | 'submission' | 'reward' | 'review';
+
+export interface StepData {
+ status: StepStatus;
+ isCompleted: boolean;
+ data?: Record;
+}
+
+/** The five wizard steps, in order. `review` carries no editable section. */
+export const STEP_ORDER: StepKey[] = [
+ 'scope',
+ 'mode',
+ 'submission',
+ 'reward',
+ 'review',
+];
+
+/**
+ * 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).
+ */
+export interface BountyFormData {
+ scope?: BountyScopeSection;
+ mode?: ModeFormData;
+ submission?: SubmissionModelFormData;
+ reward?: BountyRewardSection;
+}
+
+/**
+ * Does a step hold enough data to count as complete? Drives the tab badges and
+ * the resume "first incomplete step" computation. Never a navigation gate
+ * (navigation is free-roam); real validation happens on save + at publish.
+ */
+export const isBountyStepDataValid = (
+ stepKey: StepKey,
+ formData: BountyFormData
+): boolean => {
+ switch (stepKey) {
+ case 'scope': {
+ const scope = formData.scope;
+ return !!(scope?.title?.trim() && scope?.description?.trim());
+ }
+ case 'mode': {
+ const mode = formData.mode;
+ return !!(mode?.entryType && mode?.claimType);
+ }
+ case 'submission': {
+ return !!formData.submission?.submissionDeadline;
+ }
+ case 'reward': {
+ const reward = formData.reward;
+ return !!(
+ reward?.rewardCurrency && (reward?.prizeTiers?.length ?? 0) > 0
+ );
+ }
+ case 'review':
+ return false;
+ default:
+ return false;
+ }
+};
diff --git a/features/bounties/api/draft-client.ts b/features/bounties/api/draft-client.ts
new file mode 100644
index 00000000..479ffc3e
--- /dev/null
+++ b/features/bounties/api/draft-client.ts
@@ -0,0 +1,74 @@
+/**
+ * Non-hook bounty draft client calls on the typed openapi-fetch client. These
+ * back the React Query hooks in use-draft.ts and are available for imperative
+ * callers (e.g. a create-then-update sequence in the wizard machinery, #598).
+ * The response envelope is unwrapped by the apiClient middleware.
+ */
+import { apiClient, unwrapData } from '@/lib/api';
+
+import type { BountyDraft, UpdateBountyDraftBody } from '../types';
+
+/** Create an empty bounty draft in `draft` status. */
+export const createBountyDraft = async (
+ organizationId: string
+): Promise =>
+ unwrapData(
+ await apiClient.POST('/api/organizations/{organizationId}/bounties/draft', {
+ params: { path: { organizationId } },
+ })
+ );
+
+/** Apply one or more wizard sections in a single PATCH. */
+export const updateBountyDraft = async (
+ organizationId: string,
+ id: string,
+ body: UpdateBountyDraftBody
+): Promise =>
+ unwrapData(
+ await apiClient.PATCH(
+ '/api/organizations/{organizationId}/bounties/draft/{id}',
+ { params: { path: { organizationId, id } }, body }
+ )
+ );
+
+/** Fetch a single draft for resume. */
+export const getBountyDraft = async (
+ organizationId: string,
+ id: string
+): Promise =>
+ unwrapData(
+ await apiClient.GET(
+ '/api/organizations/{organizationId}/bounties/draft/{id}',
+ { params: { path: { organizationId, id } } }
+ )
+ );
+
+/** List an organization's drafts (draft + draft_awaiting_funding). */
+export const listBountyDrafts = async (
+ organizationId: string
+): Promise =>
+ unwrapData(
+ await apiClient.GET('/api/organizations/{organizationId}/bounties/drafts', {
+ params: { path: { organizationId } },
+ })
+ );
+
+export interface DeleteBountyDraftResult {
+ success: true;
+ message: string;
+ data: null;
+}
+
+/** Delete a draft. 204 No Content on success. */
+export const deleteBountyDraft = async (
+ organizationId: string,
+ id: string
+): Promise => {
+ await unwrapData(
+ await apiClient.DELETE(
+ '/api/organizations/{organizationId}/bounties/draft/{id}',
+ { params: { path: { organizationId, id } } }
+ )
+ );
+ return { success: true, message: 'Deleted successfully', data: null };
+};
diff --git a/features/bounties/api/escrow-client.ts b/features/bounties/api/escrow-client.ts
new file mode 100644
index 00000000..f5c1829a
--- /dev/null
+++ b/features/bounties/api/escrow-client.ts
@@ -0,0 +1,93 @@
+/**
+ * Bounty escrow API client (boundless-events contract), on the typed
+ * openapi-fetch client. Request/response shapes come from the backend-generated
+ * schema; the response envelope is unwrapped by the apiClient middleware.
+ *
+ * Every action builds an EscrowOp the webapp drives through its lifecycle:
+ * PENDING_BUILD -> PENDING_SIGN -> PENDING_SUBMIT -> PENDING_CONFIRM
+ * -> COMPLETED | FAILED | CANCELLED
+ *
+ * MANAGED : backend signs with the caller's platform-held wallet + submits;
+ * the op returns in PENDING_CONFIRM. The webapp only polls.
+ * EXTERNAL : backend returns `unsignedXdr`; the webapp signs with a connected
+ * wallet and POSTs to submit-signed, then polls.
+ *
+ * These are the organizer (org-scoped) operations the Configure / publish flow
+ * needs. Builder-facing participant escrow (apply / submit / contribute) is out
+ * of scope for the Configure epic.
+ */
+import { apiClient, unwrapData } from '@/lib/api';
+
+import type {
+ BountyEscrowOpResponse,
+ CancelBountyEscrowRequest,
+ PublishBountyEscrowRequest,
+ SelectBountyWinnersRequest,
+ SubmitSignedXdrRequest,
+} from '../types';
+
+/** Publish a bounty draft to the events contract (CREATE_EVENT). */
+export const publishBountyEscrow = async (
+ organizationId: string,
+ bountyId: string,
+ body: PublishBountyEscrowRequest
+): Promise =>
+ unwrapData(
+ await apiClient.POST(
+ '/api/organizations/{organizationId}/bounties/{id}/escrow/publish',
+ { params: { path: { organizationId, id: bountyId } }, body }
+ )
+ );
+
+/** Cancel an active bounty and refund contributors + owner. */
+export const cancelBountyEscrow = async (
+ organizationId: string,
+ bountyId: string,
+ body: CancelBountyEscrowRequest
+): Promise =>
+ unwrapData(
+ await apiClient.POST(
+ '/api/organizations/{organizationId}/bounties/{id}/escrow/cancel',
+ { params: { path: { organizationId, id: bountyId } }, body }
+ )
+ );
+
+/** Declare winners and trigger payout (SELECT_WINNERS). */
+export const selectBountyWinners = async (
+ organizationId: string,
+ bountyId: string,
+ body: SelectBountyWinnersRequest
+): Promise =>
+ unwrapData(
+ await apiClient.POST(
+ '/api/organizations/{organizationId}/bounties/{id}/escrow/select-winners',
+ { params: { path: { organizationId, id: bountyId } }, body }
+ )
+ );
+
+/** Submit a wallet-signed XDR for an organizer op (EXTERNAL path). */
+export const submitSignedBountyEscrow = async (
+ organizationId: string,
+ bountyId: string,
+ opRowId: string,
+ body: SubmitSignedXdrRequest
+): Promise =>
+ unwrapData(
+ await apiClient.POST(
+ '/api/organizations/{organizationId}/bounties/{id}/escrow/ops/{opRowId}/submit-signed',
+ { params: { path: { organizationId, id: bountyId, opRowId } }, body }
+ )
+ );
+
+/** Poll the current state of an organizer escrow op. */
+export const getBountyEscrowOp = async (
+ organizationId: string,
+ bountyId: string,
+ opRowId: string
+): Promise =>
+ unwrapData(
+ await apiClient.GET(
+ '/api/organizations/{organizationId}/bounties/{id}/escrow/ops/{opRowId}',
+ { params: { path: { organizationId, id: bountyId, opRowId } } }
+ )
+ );
diff --git a/features/bounties/api/keys.ts b/features/bounties/api/keys.ts
new file mode 100644
index 00000000..c0ca5a54
--- /dev/null
+++ b/features/bounties/api/keys.ts
@@ -0,0 +1,13 @@
+/**
+ * React Query key factory for the bounties feature. Co-locating the keys keeps
+ * the hooks and any imperative `queryClient.invalidateQueries` calls in sync.
+ */
+export const bountyKeys = {
+ all: ['bounties'] as const,
+ drafts: (organizationId: string) =>
+ [...bountyKeys.all, 'drafts', organizationId] as const,
+ draft: (organizationId: string, id: string) =>
+ [...bountyKeys.all, 'draft', organizationId, id] as const,
+ escrowOp: (scope: string, opRowId: string) =>
+ [...bountyKeys.all, 'escrow-op', scope, opRowId] as const,
+};
diff --git a/features/bounties/api/use-draft.ts b/features/bounties/api/use-draft.ts
new file mode 100644
index 00000000..1e0a0519
--- /dev/null
+++ b/features/bounties/api/use-draft.ts
@@ -0,0 +1,94 @@
+'use client';
+
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import {
+ createBountyDraft,
+ deleteBountyDraft,
+ getBountyDraft,
+ listBountyDrafts,
+ updateBountyDraft,
+} from './draft-client';
+import type { BountyDraft, UpdateBountyDraftBody } from '../types';
+import { bountyKeys } from './keys';
+
+/** Fetch a single draft. Disabled until both ids are present. */
+export function useDraft(
+ organizationId: string,
+ id: string | null | undefined
+) {
+ return useQuery({
+ queryKey:
+ organizationId && id
+ ? bountyKeys.draft(organizationId, id)
+ : [...bountyKeys.all, 'draft', 'idle'],
+ enabled: Boolean(organizationId && id),
+ queryFn: (): Promise =>
+ getBountyDraft(organizationId, id as string),
+ });
+}
+
+/** List an organization's drafts (draft + draft_awaiting_funding). */
+export function useDraftList(organizationId: string) {
+ return useQuery({
+ queryKey: bountyKeys.drafts(organizationId),
+ enabled: Boolean(organizationId),
+ queryFn: (): Promise => listBountyDrafts(organizationId),
+ });
+}
+
+/** Create an empty draft. */
+export function useCreateDraft(organizationId: string) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (): Promise => createBountyDraft(organizationId),
+ onSuccess: draft => {
+ queryClient.setQueryData(
+ bountyKeys.draft(organizationId, draft.id),
+ draft
+ );
+ queryClient.invalidateQueries({
+ queryKey: bountyKeys.drafts(organizationId),
+ });
+ },
+ });
+}
+
+/**
+ * Update one or more draft sections in a single flat PATCH. Send one section
+ * for a per-step "Continue", or several for "Save draft". The draft id is passed
+ * per-call (so a create-then-update sequence can use the fresh id with no stale
+ * closure). Seeds the draft cache with the server copy so reads stay in sync.
+ */
+export function useUpdateDraft(organizationId: string) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({
+ id,
+ body,
+ }: {
+ id: string;
+ body: UpdateBountyDraftBody;
+ }): Promise => updateBountyDraft(organizationId, id, body),
+ onSuccess: draft => {
+ queryClient.setQueryData(
+ bountyKeys.draft(organizationId, draft.id),
+ draft
+ );
+ },
+ });
+}
+
+/** Delete a draft. */
+export function useDeleteDraft(organizationId: string) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string): Promise => {
+ return deleteBountyDraft(organizationId, id).then(() => undefined);
+ },
+ onSuccess: () =>
+ queryClient.invalidateQueries({
+ queryKey: bountyKeys.drafts(organizationId),
+ }),
+ });
+}
diff --git a/features/bounties/api/use-escrow.ts b/features/bounties/api/use-escrow.ts
new file mode 100644
index 00000000..ad42f4c3
--- /dev/null
+++ b/features/bounties/api/use-escrow.ts
@@ -0,0 +1,321 @@
+'use client';
+
+/**
+ * React Query hooks for the bounty escrow flow (boundless-events).
+ *
+ * Mirrors the hackathon escrow machinery (features/hackathons/api/use-escrow):
+ * 1. useEscrowOp — polling primitive; watches an op to a terminal state.
+ * 2. mutation wrappers — usePublishBountyEscrow / useSelectBountyWinners /
+ * useCancelBountyEscrow.
+ * 3. useEscrowOpRunner — orchestrates start -> (sign+submit for EXTERNAL) ->
+ * poll-to-terminal, invalidating bounty caches on
+ * COMPLETED.
+ *
+ * Only the scope shape (org + bounty) and the publish wrapper differ from the
+ * hackathon version. MANAGED ops come back already in PENDING_CONFIRM (backend
+ * signed+submitted), so the runner just polls. EXTERNAL ops come back in
+ * PENDING_SIGN with an `unsignedXdr`; the runner signs via the injected
+ * `signXdr` and posts it to submit-signed before polling.
+ */
+import { useCallback, useEffect, useRef, useState } from 'react';
+import {
+ useMutation,
+ useQuery,
+ useQueryClient,
+ type QueryKey,
+} from '@tanstack/react-query';
+
+import {
+ cancelBountyEscrow,
+ getBountyEscrowOp,
+ publishBountyEscrow,
+ selectBountyWinners,
+ submitSignedBountyEscrow,
+} from './escrow-client';
+import {
+ isTerminalEscrowStatus,
+ type BountyEscrowOpResponse,
+ type CancelBountyEscrowRequest,
+ type EscrowOpStatus,
+ type PublishBountyEscrowRequest,
+ type SelectBountyWinnersRequest,
+} from '../types';
+
+// ─── Scope: organizer (org + bounty) ─────────────────────────────────────────
+
+export type EscrowOpScope = {
+ kind: 'organizer';
+ organizationId: string;
+ bountyId: string;
+};
+
+function getOpForScope(
+ scope: EscrowOpScope,
+ opRowId: string
+): Promise {
+ return getBountyEscrowOp(scope.organizationId, scope.bountyId, opRowId);
+}
+
+function submitSignedForScope(
+ scope: EscrowOpScope,
+ opRowId: string,
+ signedXdr: string
+): Promise {
+ return submitSignedBountyEscrow(
+ scope.organizationId,
+ scope.bountyId,
+ opRowId,
+ { signedXdr }
+ );
+}
+
+function escrowOpKey(scope: EscrowOpScope, opRowId: string): QueryKey {
+ return [
+ 'bounty-escrow-op',
+ 'organizer',
+ scope.organizationId,
+ scope.bountyId,
+ opRowId,
+ ];
+}
+
+// ─── 1. Polling primitive ────────────────────────────────────────────────────
+
+export interface UseEscrowOpOptions {
+ enabled?: boolean;
+ /** Poll cadence while the op is non-terminal. Default 2500ms. */
+ pollMs?: number;
+}
+
+/**
+ * Poll an escrow op until it reaches a terminal state. Returns the standard
+ * react-query result; `data` is the latest BountyEscrowOpResponse.
+ */
+export function useEscrowOp(
+ scope: EscrowOpScope,
+ opRowId: string | null | undefined,
+ options: UseEscrowOpOptions = {}
+) {
+ const pollMs = options.pollMs ?? 2500;
+ return useQuery({
+ queryKey: opRowId
+ ? escrowOpKey(scope, opRowId)
+ : ['bounty-escrow-op', 'idle'],
+ queryFn: () => getOpForScope(scope, opRowId as string),
+ enabled: !!opRowId && (options.enabled ?? true),
+ refetchInterval: query => {
+ const data = query.state.data as BountyEscrowOpResponse | undefined;
+ if (!data) return pollMs;
+ return isTerminalEscrowStatus(data.status) ? false : pollMs;
+ },
+ // Pending ops change server-side; never treat a non-terminal row as fresh.
+ staleTime: 0,
+ });
+}
+
+// ─── 2. Mutation wrappers ────────────────────────────────────────────────────
+
+export function usePublishBountyEscrow(
+ organizationId: string,
+ bountyId: string
+) {
+ return useMutation({
+ mutationFn: (body: PublishBountyEscrowRequest) =>
+ publishBountyEscrow(organizationId, bountyId, body),
+ });
+}
+
+export function useSelectBountyWinners(
+ organizationId: string,
+ bountyId: string
+) {
+ return useMutation({
+ mutationFn: (body: SelectBountyWinnersRequest) =>
+ selectBountyWinners(organizationId, bountyId, body),
+ });
+}
+
+export function useCancelBountyEscrow(
+ organizationId: string,
+ bountyId: string
+) {
+ return useMutation({
+ mutationFn: (body: CancelBountyEscrowRequest) =>
+ cancelBountyEscrow(organizationId, bountyId, body),
+ });
+}
+
+// ─── 3. Orchestrating runner ─────────────────────────────────────────────────
+
+/** Sign an unsigned XDR for the EXTERNAL path. */
+export type SignXdrFn = (
+ unsignedXdr: string,
+ signerHint?: string | null
+) => Promise;
+
+export type EscrowRunPhase =
+ | 'idle'
+ | 'starting'
+ | 'signing'
+ | 'submitting'
+ | 'polling'
+ | 'completed'
+ | 'failed';
+
+export interface UseEscrowOpRunnerOptions {
+ /**
+ * Signer for the EXTERNAL path. When the start op returns PENDING_SIGN with
+ * an unsignedXdr, the runner calls this then posts to submit-signed. Omit for
+ * MANAGED-only flows (the op is already PENDING_CONFIRM).
+ */
+ signXdr?: SignXdrFn;
+ /**
+ * Query keys to invalidate when the op COMPLETES. Defaults to the whole
+ * `['bounties']` tree so a status transition renders.
+ */
+ invalidateKeys?: QueryKey[];
+ pollMs?: number;
+}
+
+export interface EscrowOpRunner {
+ /** Kick off an op. `start` is the mutation call returning the built op. */
+ run: (
+ start: () => Promise
+ ) => Promise;
+ reset: () => void;
+ /** Latest op (polled if available, else the build result). */
+ op: BountyEscrowOpResponse | null;
+ status: EscrowOpStatus | 'IDLE';
+ phase: EscrowRunPhase;
+ /** True while building/signing/submitting/confirming. */
+ isRunning: boolean;
+ isCompleted: boolean;
+ isFailed: boolean;
+ isTerminal: boolean;
+ /** Friendly error text (contract errorCode or thrown message). */
+ error: string | null;
+ txHash: string | null;
+}
+
+const DEFAULT_INVALIDATE: QueryKey[] = [['bounties']];
+
+/**
+ * Drive a single escrow op from start to terminal state, handling the
+ * MANAGED-vs-EXTERNAL signing branch and cache invalidation on completion.
+ */
+export function useEscrowOpRunner(
+ scope: EscrowOpScope,
+ options: UseEscrowOpRunnerOptions = {}
+): EscrowOpRunner {
+ const queryClient = useQueryClient();
+ const [opRowId, setOpRowId] = useState(null);
+ const [startOp, setStartOp] = useState(null);
+ const [phase, setPhase] = useState('idle');
+ const [error, setError] = useState(null);
+ // Guard so the terminal-side-effect (invalidate) fires once per op.
+ const settledRef = useRef(null);
+
+ const { signXdr, pollMs } = options;
+ const invalidateKeys = options.invalidateKeys ?? DEFAULT_INVALIDATE;
+
+ const poll = useEscrowOp(scope, opRowId, { enabled: !!opRowId, pollMs });
+
+ const op = poll.data ?? startOp;
+ const status: EscrowOpStatus | 'IDLE' = op?.status ?? 'IDLE';
+
+ // React to terminal transitions observed by the poller.
+ useEffect(() => {
+ if (!op || !opRowId) return;
+ if (!isTerminalEscrowStatus(op.status)) return;
+ if (settledRef.current === opRowId) return;
+ settledRef.current = opRowId;
+
+ if (op.status === 'COMPLETED') {
+ setPhase('completed');
+ invalidateKeys.forEach(key =>
+ queryClient.invalidateQueries({ queryKey: key })
+ );
+ } else {
+ setPhase('failed');
+ setError(prev => prev ?? op.errorCode ?? `Escrow op ${op.status}`);
+ }
+ // queryClient + invalidateKeys are stable enough; op.status is the trigger.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [op?.status, opRowId]);
+
+ const reset = useCallback(() => {
+ setOpRowId(null);
+ setStartOp(null);
+ setPhase('idle');
+ setError(null);
+ settledRef.current = null;
+ }, []);
+
+ const run = useCallback(
+ async (
+ start: () => Promise
+ ): Promise => {
+ setError(null);
+ settledRef.current = null;
+ setPhase('starting');
+ try {
+ const built = await start();
+ setStartOp(built);
+ setOpRowId(built.id);
+
+ // EXTERNAL: the op needs a wallet signature before it can settle.
+ if (built.status === 'PENDING_SIGN' && built.unsignedXdr && signXdr) {
+ setPhase('signing');
+ const signed = await signXdr(built.unsignedXdr, built.signerHint);
+ setPhase('submitting');
+ const submitted = await submitSignedForScope(scope, built.id, signed);
+ setStartOp(submitted);
+ setPhase('polling');
+ return submitted;
+ }
+
+ if (built.status === 'PENDING_SIGN' && !signXdr) {
+ // External op with no signer available — can't progress.
+ setPhase('failed');
+ setError(
+ 'This op needs a connected wallet signature, but no signer is ' +
+ 'configured. Use the managed wallet or connect a wallet.'
+ );
+ return built;
+ }
+
+ // MANAGED (already PENDING_CONFIRM) or terminal: let the poller finish.
+ setPhase('polling');
+ return built;
+ } catch (err) {
+ setPhase('failed');
+ setError(
+ err instanceof Error ? err.message : 'Failed to run escrow op'
+ );
+ return null;
+ }
+ },
+ [scope, signXdr]
+ );
+
+ const isTerminal = status !== 'IDLE' && isTerminalEscrowStatus(status);
+
+ return {
+ run,
+ reset,
+ op,
+ status,
+ phase,
+ isRunning:
+ phase === 'starting' ||
+ phase === 'signing' ||
+ phase === 'submitting' ||
+ (phase === 'polling' && !isTerminal),
+ isCompleted: status === 'COMPLETED',
+ isFailed:
+ status === 'FAILED' || status === 'CANCELLED' || phase === 'failed',
+ isTerminal,
+ error,
+ txHash: op?.txHash ?? null,
+ };
+}
diff --git a/features/bounties/index.ts b/features/bounties/index.ts
new file mode 100644
index 00000000..533f6fc5
--- /dev/null
+++ b/features/bounties/index.ts
@@ -0,0 +1,83 @@
+/**
+ * Bounties feature public surface. Import from `@/features/bounties` rather than
+ * reaching into the api/ internals.
+ */
+
+// Types (aliased from the backend-generated schema).
+export type {
+ BountyDraft,
+ BountyDraftData,
+ UpdateBountyDraftBody,
+ BountyDraftPrizeTier,
+ BountyScopeSection,
+ BountyModeSection,
+ BountySubmissionSection,
+ BountyRewardSection,
+ BountyClaimType,
+ BountyEntryType,
+ BountySubmissionVisibility,
+ DraftSection,
+ BountyEscrowOpResponse,
+ EscrowOpStatus,
+ EscrowOpKind,
+ PublishBountyEscrowRequest,
+ CancelBountyEscrowRequest,
+ SelectBountyWinnersRequest,
+ SubmitSignedXdrRequest,
+ BountyWinnerSelection,
+ BountyWinnerDistributionEntry,
+ FundingMode,
+} from './types';
+export {
+ DRAFT_SECTIONS,
+ TERMINAL_ESCROW_STATUSES,
+ isTerminalEscrowStatus,
+} from './types';
+
+// Query keys.
+export { bountyKeys } from './api/keys';
+
+// Draft client (imperative helpers).
+export {
+ createBountyDraft,
+ updateBountyDraft,
+ getBountyDraft,
+ listBountyDrafts,
+ deleteBountyDraft,
+} from './api/draft-client';
+export type { DeleteBountyDraftResult } from './api/draft-client';
+
+// Draft hooks (React Query).
+export {
+ useDraft,
+ useDraftList,
+ useCreateDraft,
+ useUpdateDraft,
+ useDeleteDraft,
+} from './api/use-draft';
+
+// Escrow client (typed openapi-fetch).
+export {
+ publishBountyEscrow,
+ cancelBountyEscrow,
+ selectBountyWinners,
+ submitSignedBountyEscrow,
+ getBountyEscrowOp,
+} from './api/escrow-client';
+
+// Escrow hooks (React Query: polling primitive, mutation wrappers, op runner).
+export {
+ useEscrowOp,
+ useEscrowOpRunner,
+ usePublishBountyEscrow,
+ useSelectBountyWinners,
+ useCancelBountyEscrow,
+} from './api/use-escrow';
+export type {
+ EscrowOpScope,
+ SignXdrFn,
+ EscrowRunPhase,
+ UseEscrowOpOptions,
+ UseEscrowOpRunnerOptions,
+ EscrowOpRunner,
+} from './api/use-escrow';
diff --git a/features/bounties/types.ts b/features/bounties/types.ts
new file mode 100644
index 00000000..d2aee8bb
--- /dev/null
+++ b/features/bounties/types.ts
@@ -0,0 +1,75 @@
+/**
+ * Bounty feature types.
+ *
+ * Every server shape is aliased from the backend-generated OpenAPI schema
+ * (lib/api/generated/schema.d.ts), so they never drift from boundless-nestjs.
+ * Run `npm run codegen` after a backend DTO change to refresh them. Do not
+ * hand-write server DTOs here.
+ */
+import type { Schemas } from '@/lib/api';
+
+// ── Draft ────────────────────────────────────────────────────────────────────
+
+/** Full draft as returned by GET/PATCH /draft/:id. */
+export type BountyDraft = Schemas['BountyDraftResponseDto'];
+/** Section-keyed draft payload (scope, mode, submission, reward). */
+export type BountyDraftData = Schemas['BountyDraftDataDto'];
+/** Flat draft-update body: send any subset of sections in one PATCH. */
+export type UpdateBountyDraftBody = Schemas['UpdateBountyDraftDto'];
+/** Prize tier exposed on the draft response (position + decimal amount). */
+export type BountyDraftPrizeTier = Schemas['BountyDraftPrizeTierDto'];
+
+// Section DTOs (the wire shape the backend persists + returns).
+export type BountyScopeSection = Schemas['BountyScopeSectionDto'];
+export type BountyModeSection = Schemas['BountyModeSectionDto'];
+export type BountySubmissionSection = Schemas['BountySubmissionSectionDto'];
+export type BountyRewardSection = Schemas['BountyRewardSectionDto'];
+
+/** Two-axis taxonomy, derived from the generated section DTO so it stays in
+ * lockstep with the backend enums (supersedes the local stubs in the ModeTab). */
+export type BountyClaimType = BountyModeSection['claimType'];
+export type BountyEntryType = BountyModeSection['entryType'];
+export type BountySubmissionVisibility = NonNullable<
+ BountySubmissionSection['submissionVisibility']
+>;
+
+/** The four editable wizard sections, in order. */
+export const DRAFT_SECTIONS = [
+ 'scope',
+ 'mode',
+ 'submission',
+ 'reward',
+] as const;
+export type DraftSection = (typeof DRAFT_SECTIONS)[number];
+
+// ── Escrow ───────────────────────────────────────────────────────────────────
+
+/** The EscrowOp row returned by every bounty escrow endpoint. */
+export type BountyEscrowOpResponse = Schemas['BountyEscrowOpResponseDto'];
+/** Op lifecycle status (derived from the generated union). */
+export type EscrowOpStatus = BountyEscrowOpResponse['status'];
+/** Contract operation kind (derived from the generated union). */
+export type EscrowOpKind = BountyEscrowOpResponse['kind'];
+
+export type PublishBountyEscrowRequest = Schemas['PublishBountyEscrowDto'];
+export type CancelBountyEscrowRequest = Schemas['CancelBountyEscrowDto'];
+export type SelectBountyWinnersRequest = Schemas['SelectBountyWinnersDto'];
+export type SubmitSignedXdrRequest = Schemas['BountySubmitSignedXdrDto'];
+export type BountyWinnerSelection = Schemas['BountyWinnerSelectionDto'];
+export type BountyWinnerDistributionEntry =
+ Schemas['BountyWinnerDistributionEntryDto'];
+
+/** Signing path for an escrow op. */
+export type FundingMode = NonNullable<
+ PublishBountyEscrowRequest['fundingMode']
+>;
+
+/** Terminal op states. Polling should stop once one is reached. */
+export const TERMINAL_ESCROW_STATUSES: readonly EscrowOpStatus[] = [
+ 'COMPLETED',
+ 'FAILED',
+ 'CANCELLED',
+];
+
+export const isTerminalEscrowStatus = (status: EscrowOpStatus): boolean =>
+ TERMINAL_ESCROW_STATUSES.includes(status);
diff --git a/hooks/use-bounty-draft.ts b/hooks/use-bounty-draft.ts
new file mode 100644
index 00000000..ec47f258
--- /dev/null
+++ b/hooks/use-bounty-draft.ts
@@ -0,0 +1,183 @@
+import { useEffect, useRef, useState } from 'react';
+import { toast } from 'sonner';
+
+import {
+ BountyFormData,
+ STEP_ORDER,
+ isBountyStepDataValid,
+ type StepKey,
+} from '@/components/organization/bounties/new/constants';
+import {
+ DRAFT_SECTIONS,
+ useCreateDraft,
+ useDraft,
+ useUpdateDraft,
+ type BountyDraft,
+ type UpdateBountyDraftBody,
+} from '@/features/bounties';
+
+/** A `datetime-local` input wants `YYYY-MM-DDTHH:mm`; trim an ISO string to it. */
+const toLocalInput = (iso: string | null | undefined): string =>
+ iso ? iso.slice(0, 16) : '';
+
+/**
+ * Hydrate the wizard form state from a saved draft. The backend returns each
+ * section flat under `draft.data` (the generated BountyDraftData shape), so most
+ * fields read directly. `mode.winnerCount` is UI-only and derived from the
+ * number of prize tiers; dates are trimmed for the datetime-local inputs.
+ */
+export const transformBountyFromApi = (draft: BountyDraft): BountyFormData => {
+ const scope = draft.data?.scope;
+ const mode = draft.data?.mode;
+ const submission = draft.data?.submission;
+ const reward = draft.data?.reward;
+
+ const winnerCount = reward?.prizeTiers?.length || 1;
+
+ return {
+ scope: scope ? { ...scope } : undefined,
+ mode:
+ mode?.entryType && mode?.claimType
+ ? {
+ entryType: mode.entryType,
+ claimType: mode.claimType,
+ winnerCount,
+ }
+ : undefined,
+ submission: submission
+ ? {
+ ...submission,
+ submissionDeadline: toLocalInput(submission.submissionDeadline),
+ applicationWindowCloseAt: submission.applicationWindowCloseAt
+ ? toLocalInput(submission.applicationWindowCloseAt)
+ : null,
+ // Required by the form; the mode forces it when unset on the draft.
+ submissionVisibility:
+ submission.submissionVisibility ??
+ (mode?.claimType === 'COMPETITION'
+ ? 'HIDDEN_UNTIL_DEADLINE'
+ : 'ORGANIZER_ONLY'),
+ }
+ : undefined,
+ reward: reward ? { ...reward } : undefined,
+ };
+};
+
+type SectionValue = NonNullable;
+
+interface UseBountyDraftProps {
+ organizationId?: string;
+ initialDraftId?: string;
+ onDraftLoaded?: (
+ formData: BountyFormData,
+ firstIncompleteStep: StepKey
+ ) => void;
+}
+
+/**
+ * Wizard draft orchestration on React Query, mirroring useHackathonDraft. Loads
+ * an existing draft (resume) via `useDraft`, creates on first save via
+ * `useCreateDraft` (lazy `ensureDraftId`), and persists sections through the
+ * single flat `useUpdateDraft` PATCH. Server state lives in the query cache;
+ * only the in-progress form snapshot is local.
+ */
+export const useBountyDraft = ({
+ organizationId,
+ initialDraftId,
+ onDraftLoaded,
+}: UseBountyDraftProps) => {
+ const [draftId, setDraftId] = useState(initialDraftId || null);
+ const [stepData, setStepData] = useState({});
+ const loadedRef = useRef(null);
+
+ const orgId = organizationId ?? '';
+ // Only fetch for the resume flow (an initial draft id). Fresh drafts keep
+ // their form state locally until the first save.
+ const draftQuery = useDraft(orgId, initialDraftId);
+ const createDraft = useCreateDraft(orgId);
+ const updateDraft = useUpdateDraft(orgId);
+
+ useEffect(() => {
+ const draft = draftQuery.data;
+ if (!draft || !initialDraftId || draft.id !== initialDraftId) return;
+ if (loadedRef.current === draft.id) return;
+ loadedRef.current = draft.id;
+
+ const formData = transformBountyFromApi(draft);
+ setStepData(formData);
+ if (!draftId) setDraftId(draft.id);
+
+ const firstIncompleteStep =
+ STEP_ORDER.find(key => !isBountyStepDataValid(key, formData)) ?? 'review';
+ onDraftLoaded?.(formData, firstIncompleteStep);
+ }, [draftQuery.data, initialDraftId, draftId, onDraftLoaded]);
+
+ /** Resolve the draft id, creating an empty draft on first use. */
+ const ensureDraftId = async (): Promise => {
+ if (draftId) return draftId;
+ const draft = await createDraft.mutateAsync();
+ setDraftId(draft.id);
+ return draft.id;
+ };
+
+ const saveDraft = async () => {
+ if (!organizationId) {
+ toast.error('Organization ID is required');
+ return;
+ }
+ try {
+ const id = await ensureDraftId();
+ const body: Record = {};
+ for (const section of DRAFT_SECTIONS) {
+ const value = stepData[section];
+ if (value !== undefined) body[section] = value;
+ }
+ if (Object.keys(body).length > 0) {
+ await updateDraft.mutateAsync({
+ id,
+ body: body as UpdateBountyDraftBody,
+ });
+ }
+ toast.success('Draft saved successfully');
+ } catch {
+ toast.error('Failed to save draft');
+ throw new Error('Failed to save draft');
+ }
+ };
+
+ /**
+ * Persist one section. `data` is the section's form snapshot; extra UI-only
+ * fields (e.g. mode.winnerCount) are stripped by the backend Zod section
+ * schema, so we can send the form value as-is.
+ */
+ const saveStep = async (stepKey: StepKey, data: SectionValue) => {
+ if (!organizationId) {
+ toast.error('Organization ID is required');
+ return;
+ }
+ const id = await ensureDraftId();
+ await updateDraft.mutateAsync({
+ id,
+ body: { [stepKey]: data } as unknown as UpdateBountyDraftBody,
+ });
+ const updated = { ...stepData, [stepKey]: data };
+ setStepData(updated);
+ return updated;
+ };
+
+ return {
+ draftId,
+ /** Server status ('draft' | 'draft_awaiting_funding'); undefined for an unsaved /new draft. */
+ draftStatus: draftQuery.data?.status,
+ stepData,
+ setStepData,
+ isLoadingDraft: Boolean(initialDraftId) && draftQuery.isLoading,
+ currentError:
+ initialDraftId && draftQuery.error
+ ? draftQuery.error.message || 'Failed to load draft'
+ : null,
+ isSavingDraft: createDraft.isPending || updateDraft.isPending,
+ saveDraft,
+ saveStep,
+ };
+};
diff --git a/hooks/use-bounty-steps.ts b/hooks/use-bounty-steps.ts
new file mode 100644
index 00000000..29de743f
--- /dev/null
+++ b/hooks/use-bounty-steps.ts
@@ -0,0 +1,130 @@
+'use client';
+
+import { useCallback, useState } from 'react';
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+
+import type {
+ StepKey,
+ StepData,
+ StepStatus,
+} from '@/components/organization/bounties/new/constants';
+import { STEP_ORDER } from '@/components/organization/bounties/new/constants';
+
+interface UseBountyStepsReturn {
+ activeTab: StepKey;
+ steps: Record;
+ setActiveTab: (tab: StepKey) => void;
+ setStepsFromDraft: (
+ steps: Record,
+ activeStep: StepKey
+ ) => void;
+ navigateToStep: (stepKey: StepKey) => void;
+ updateStepCompletion: (
+ stepKey: StepKey,
+ isCompleted: boolean,
+ nextStep?: StepKey
+ ) => void;
+}
+
+function isStepKey(value: string | null | undefined): value is StepKey {
+ return !!value && (STEP_ORDER as readonly string[]).includes(value);
+}
+
+/**
+ * Wizard step state for the bounty Configure flow. The active step is URL-driven
+ * via the `?step=` query param, so refresh, back/forward, and bookmarking resume
+ * the right step. Navigation is free-roam: any step is reachable at any time;
+ * the per-step status map is purely presentational. Mirrors useHackathonSteps.
+ */
+export const useBountySteps = (
+ initialActiveTab: StepKey = 'scope'
+): UseBountyStepsReturn => {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ const urlStep = searchParams.get('step');
+ const activeTab: StepKey = isStepKey(urlStep) ? urlStep : initialActiveTab;
+
+ const [steps, setSteps] = useState>({
+ scope: { status: 'active', isCompleted: false },
+ mode: { status: 'pending', isCompleted: false },
+ submission: { status: 'pending', isCompleted: false },
+ reward: { status: 'pending', isCompleted: false },
+ review: { status: 'pending', isCompleted: false },
+ });
+
+ // Write the active step to the URL. `push` for explicit navigation (so back
+ // steps through), `replace` for programmatic syncs (auto-resume).
+ const writeStep = useCallback(
+ (stepKey: StepKey, mode: 'push' | 'replace' = 'push') => {
+ const params = new URLSearchParams(searchParams.toString());
+ params.set('step', stepKey);
+ const url = `${pathname}?${params.toString()}`;
+ if (mode === 'replace') router.replace(url, { scroll: false });
+ else router.push(url, { scroll: false });
+ },
+ [searchParams, pathname, router]
+ );
+
+ const setActiveTab = useCallback(
+ (tab: StepKey) => writeStep(tab, 'push'),
+ [writeStep]
+ );
+
+ const navigateToStep = useCallback(
+ (stepKey: StepKey) => {
+ setSteps(prev => ({
+ ...prev,
+ [stepKey]: { ...prev[stepKey], status: 'active' as StepStatus },
+ }));
+ writeStep(stepKey, 'push');
+ },
+ [writeStep]
+ );
+
+ const setStepsFromDraft = useCallback(
+ (stepsState: Record, activeStep: StepKey) => {
+ setSteps(stepsState);
+ // Respect an explicit ?step= (refresh / bookmark); otherwise fall back to
+ // the computed first-incomplete step as a replace (no extra history entry).
+ if (!isStepKey(searchParams.get('step'))) {
+ writeStep(activeStep, 'replace');
+ }
+ },
+ [searchParams, writeStep]
+ );
+
+ const updateStepCompletion = useCallback(
+ (stepKey: StepKey, isCompleted: boolean, nextStep?: StepKey) => {
+ setSteps(prev => {
+ const newSteps: Record = {
+ ...prev,
+ [stepKey]: {
+ ...prev[stepKey],
+ status: 'completed' as StepStatus,
+ isCompleted,
+ },
+ };
+ if (nextStep) {
+ newSteps[nextStep] = {
+ ...prev[nextStep],
+ status: 'active' as StepStatus,
+ };
+ }
+ return newSteps;
+ });
+ if (nextStep) writeStep(nextStep, 'push');
+ },
+ [writeStep]
+ );
+
+ return {
+ activeTab,
+ steps,
+ setActiveTab,
+ setStepsFromDraft,
+ navigateToStep,
+ updateStepCompletion,
+ };
+};
diff --git a/lib/api/generated/schema.d.ts b/lib/api/generated/schema.d.ts
index 4ff07960..92e9db7b 100644
--- a/lib/api/generated/schema.d.ts
+++ b/lib/api/generated/schema.d.ts
@@ -3598,6 +3598,70 @@ export interface paths {
patch?: never;
trace?: never;
};
+ '/api/organizations/{organizationId}/hackathons/{idOrSlug}/judging/winners/board': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get the winners board
+ * @description One row per prize placement (overall and per-track), each with the score-ranked default pick, the current selection, the candidate list to choose from, and a conflict flag when a project already holds another placement. Drives the organizer "review and pick winners" step.
+ */
+ get: operations['OrganizationHackathonsJudgingController_winnersBoard'];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ '/api/organizations/{organizationId}/hackathons/{idOrSlug}/judging/winners/placements/{placementId}': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ /**
+ * Pick the winner for a placement
+ * @description Pins a submission to a prize placement as an organizer override (saved as a draft until results are published). A project already holding another placement is allowed; the UI confirms the stacking inline.
+ */
+ put: operations['OrganizationHackathonsJudgingController_setPlacementWinner'];
+ post?: never;
+ /**
+ * Clear an organizer pick for a placement
+ * @description Removes the organizer override draft for a placement, reverting it to the score-based default. Only valid before results are published.
+ */
+ delete: operations['OrganizationHackathonsJudgingController_clearPlacementWinner'];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ '/api/organizations/{organizationId}/hackathons/{idOrSlug}/judging/winners/placements/{placementId}/withhold': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Leave a placement unawarded
+ * @description Deliberately leaves a prize placement vacant ("no submission earned this prize"). Clears any pick and marks it withheld so the allocator skips it. Distinct from clearing back to the score-based default. Only valid before results are published.
+ */
+ post: operations['OrganizationHackathonsJudgingController_withholdPlacement'];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
'/api/organizations/{organizationId}/hackathons/{idOrSlug}/judging/invitations': {
parameters: {
query?: never;
@@ -7357,6 +7421,94 @@ export interface paths {
patch?: never;
trace?: never;
};
+ '/api/organizations/{organizationId}/bounties/draft/{id}': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get a bounty draft for resume
+ * @description Returns the current section-keyed state of a bounty draft.
+ */
+ get: operations['OrganizationBountiesDraftsController_getDraft'];
+ put?: never;
+ post?: never;
+ /**
+ * Delete a bounty draft
+ * @description Deletes an unpublished bounty (draft / draft_awaiting_funding).
+ */
+ delete: operations['OrganizationBountiesDraftsController_deleteDraft'];
+ options?: never;
+ head?: never;
+ /**
+ * Update one or more sections of a bounty draft
+ * @description Applies any subset of wizard sections in a single PATCH. Send one section for a per-step "Continue", or several for "Save draft". Each present section is validated and transformed independently, then merged into one write. The reward section replaces the prize tiers.
+ */
+ patch: operations['OrganizationBountiesDraftsController_updateDraft'];
+ trace?: never;
+ };
+ '/api/organizations/{organizationId}/bounties': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get an organization's published bounties
+ * @description Lists bounties for an organization, newest first.
+ */
+ get: operations['OrganizationBountiesDraftsController_getOrganizationBounties'];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ '/api/organizations/{organizationId}/bounties/draft': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Create a new bounty draft for an organization
+ * @description Creates an empty bounty in draft status that organization members can edit section by section through the Configure wizard.
+ */
+ post: operations['OrganizationBountiesDraftsController_createDraft'];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ '/api/organizations/{organizationId}/bounties/drafts': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get an organization's bounty drafts
+ * @description Lists draft and draft_awaiting_funding bounties for an organization.
+ */
+ get: operations['OrganizationBountiesDraftsController_getOrganizationDrafts'];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
'/api/organizations/{organizationId}/bounties/{id}/escrow/publish': {
parameters: {
query?: never;
@@ -16326,6 +16478,10 @@ export interface components {
};
metadata: components['schemas']['JudgingResultsMetadataDto'];
};
+ SetPlacementWinnerDto: {
+ /** @description The submission to award this placement to. */
+ submissionId: string;
+ };
InviteJudgeDto: {
/** @example judge@example.com */
email: string;
@@ -17810,6 +17966,93 @@ export interface components {
reason: string;
};
Function: Record;
+ BountyScopeSectionDto: {
+ /** @description Bounty title */
+ title: string;
+ /** @description Bounty description */
+ description: string;
+ /** Format: uri */
+ githubIssueUrl?: string | null;
+ githubIssueNumber?: number | null;
+ projectId?: string | null;
+ bountyWindowId?: string | null;
+ };
+ BountyModeSectionDto: {
+ /** @enum {string} */
+ claimType: 'SINGLE_CLAIM' | 'COMPETITION';
+ /** @enum {string} */
+ entryType: 'OPEN' | 'APPLICATION_LIGHT' | 'APPLICATION_FULL';
+ };
+ BountySubmissionSectionDto: {
+ /** Format: date-time */
+ submissionDeadline: string;
+ /** Format: date-time */
+ applicationWindowCloseAt?: string | null;
+ maxApplicants?: number | null;
+ shortlistSize?: number | null;
+ reputationMinimum?: number | null;
+ /** @enum {string} */
+ submissionVisibility?: 'ORGANIZER_ONLY' | 'HIDDEN_UNTIL_DEADLINE';
+ /** @description Credits required to apply (anti-spam). Supplied to the contract at publish via PublishBountyEscrowDto; not a Bounty column. */
+ applicationCreditCost?: number | null;
+ };
+ BountyPrizeTierInputDto: {
+ /** @description 1 = 1st place; unique within a bounty */
+ position: number;
+ /** @description Tier amount as a positive decimal string */
+ amount: string;
+ passMark?: number | null;
+ };
+ BountyRewardSectionDto: {
+ /** @description Token / currency code the prize is denominated in */
+ rewardCurrency: string;
+ /** @description 1 tier for single claim; 1-3 tiers for a competition (multiple winners). */
+ prizeTiers: components['schemas']['BountyPrizeTierInputDto'][];
+ };
+ BountyDraftDataDto: {
+ scope?: components['schemas']['BountyScopeSectionDto'];
+ mode?: components['schemas']['BountyModeSectionDto'];
+ submission?: components['schemas']['BountySubmissionSectionDto'];
+ reward?: components['schemas']['BountyRewardSectionDto'];
+ };
+ BountyDraftPrizeTierDto: {
+ position: number;
+ /** @description Tier amount as a decimal string */
+ amount: string;
+ passMark?: number | null;
+ };
+ BountyDraftResponseDto: {
+ id: string;
+ /**
+ * @description Bounty lifecycle state (lowercase).
+ * @example draft
+ */
+ status: string;
+ /** @description First incomplete step (1-based) */
+ currentStep: number;
+ completedSteps: string[];
+ /** @description Plain mode label derived from (entryType, claimType, winner count), e.g. "Open competition (multiple winners)". */
+ modeLabel?: string | null;
+ data: components['schemas']['BountyDraftDataDto'];
+ prizeTiers: components['schemas']['BountyDraftPrizeTierDto'][];
+ isValidForPublish: boolean;
+ /** @description Per-section validation messages, keyed by step */
+ validationErrors: {
+ [key: string]: Record[];
+ };
+ /** Format: date-time */
+ createdAt: string;
+ /** Format: date-time */
+ updatedAt: string;
+ };
+ UpdateBountyDraftDto: {
+ scope?: components['schemas']['BountyScopeSectionDto'];
+ mode?: components['schemas']['BountyModeSectionDto'];
+ submission?: components['schemas']['BountySubmissionSectionDto'];
+ reward?: components['schemas']['BountyRewardSectionDto'];
+ /** @description Hint that this is an autosave (no completion side effects). */
+ autoSave?: boolean;
+ };
BountyWinnerDistributionEntryDto: {
/**
* @description 1-indexed winner position.
@@ -25676,6 +25919,136 @@ export interface operations {
};
};
};
+ OrganizationHackathonsJudgingController_winnersBoard: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Organization ID */
+ organizationId: string;
+ /** @description Hackathon ID or slug */
+ idOrSlug: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Invalid request */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Resource not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ OrganizationHackathonsJudgingController_setPlacementWinner: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Organization ID */
+ organizationId: string;
+ /** @description Hackathon ID or slug */
+ idOrSlug: string;
+ /** @description Prize placement id */
+ placementId: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['SetPlacementWinnerDto'];
+ };
+ };
+ responses: {
+ /** @description Invalid request */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Resource not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ OrganizationHackathonsJudgingController_clearPlacementWinner: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Organization ID */
+ organizationId: string;
+ /** @description Hackathon ID or slug */
+ idOrSlug: string;
+ /** @description Prize placement id */
+ placementId: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Invalid request */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Resource not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ OrganizationHackathonsJudgingController_withholdPlacement: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Organization ID */
+ organizationId: string;
+ /** @description Hackathon ID or slug */
+ idOrSlug: string;
+ /** @description Prize placement id */
+ placementId: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Invalid request */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Resource not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
OrganizationHackathonsJudgingController_listInvitations: {
parameters: {
query?: never;
@@ -32073,6 +32446,168 @@ export interface operations {
};
};
};
+ OrganizationBountiesDraftsController_getDraft: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ organizationId: string;
+ /** @description Bounty draft id */
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Draft retrieved */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['BountyDraftResponseDto'];
+ };
+ };
+ };
+ };
+ OrganizationBountiesDraftsController_deleteDraft: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ organizationId: string;
+ /** @description Bounty draft id */
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Draft deleted */
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Draft not found, not authorized, or not an unpublished draft */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ OrganizationBountiesDraftsController_updateDraft: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ organizationId: string;
+ /** @description Bounty draft id */
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['UpdateBountyDraftDto'];
+ };
+ };
+ responses: {
+ /** @description Draft updated */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['BountyDraftResponseDto'];
+ };
+ };
+ /** @description Validation failed for one or more sections */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ OrganizationBountiesDraftsController_getOrganizationBounties: {
+ parameters: {
+ query: {
+ page: number;
+ limit: number;
+ };
+ header?: never;
+ path: {
+ organizationId: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Bounties retrieved */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ OrganizationBountiesDraftsController_createDraft: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ organizationId: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Draft created */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['BountyDraftResponseDto'];
+ };
+ };
+ /** @description User is not a member of the organization */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ OrganizationBountiesDraftsController_getOrganizationDrafts: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ organizationId: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Drafts retrieved */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['BountyDraftResponseDto'][];
+ };
+ };
+ };
+ };
OrganizationBountiesEscrowController_publish: {
parameters: {
query?: never;