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 ( +
+
+
+ Loading draft... +
+
+ ); + } + + 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;