From 77b2fd13d13acf2baf049b72fc9123819e381b30 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Tue, 16 Jun 2026 14:39:36 +0100 Subject: [PATCH 1/5] feat(bounty): features/bounties data layer (types, keys, draft + escrow) (#596) Adds the features/bounties/ data layer for the v1 app, mirroring features/hackathons/. REST-only via the typed openapi-fetch client; every server shape is aliased from the backend-generated schema so it cannot drift. - types.ts: aliases the generated draft + escrow DTOs (BountyDraft, UpdateBountyDraftBody, the four section types, BountyEscrowOpResponse, PublishBountyEscrowRequest, ...). Derives the two-axis taxonomy from the generated mode DTO, superseding the local stubs in the ModeTab. - api/keys.ts: bountyKeys factory. - api/draft-client.ts + use-draft.ts: imperative CRUD plus the useDraft / useDraftList / useCreateDraft / useUpdateDraft / useDeleteDraft hooks against /organizations/{organizationId}/bounties/draft[/{id}] + /drafts. - api/escrow-client.ts + use-escrow.ts: organizer escrow calls (publish / cancel / select-winners / submit-signed / poll) plus useEscrowOp + useEscrowOpRunner, mirroring the hackathon machinery (MANAGED polls; EXTERNAL signs -> submit -> poll) bounty-scoped. - index.ts: public surface. Regenerates lib/api/generated/schema.d.ts from the v2 backend so the bounty draft paths/DTOs are present. --- features/bounties/api/draft-client.ts | 74 ++++ features/bounties/api/escrow-client.ts | 93 +++++ features/bounties/api/keys.ts | 13 + features/bounties/api/use-draft.ts | 94 +++++ features/bounties/api/use-escrow.ts | 321 +++++++++++++++ features/bounties/index.ts | 83 ++++ features/bounties/types.ts | 75 ++++ lib/api/generated/schema.d.ts | 535 +++++++++++++++++++++++++ 8 files changed, 1288 insertions(+) create mode 100644 features/bounties/api/draft-client.ts create mode 100644 features/bounties/api/escrow-client.ts create mode 100644 features/bounties/api/keys.ts create mode 100644 features/bounties/api/use-draft.ts create mode 100644 features/bounties/api/use-escrow.ts create mode 100644 features/bounties/index.ts create mode 100644 features/bounties/types.ts 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/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; From 5c0d24e58bde81f38885f8c18f000860b8d264c6 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Tue, 16 Jun 2026 15:57:37 +0100 Subject: [PATCH 2/5] feat(bounty): wizard shell + step/draft machinery (#598) Adds the bounty Configure wizard orchestrator and its step + draft state, mirroring the hackathon wizard. No AI assist. - components/organization/bounties/new/constants.ts: StepKey (scope/mode/submission/reward/review), STEP_ORDER, BountyFormData, and isBountyStepDataValid. - hooks/use-bounty-steps.ts: URL ?step= navigation (free-roam) with a presentational step-status map. - hooks/use-bounty-draft.ts: lazy create (ensureDraftId) then per-section PATCH, resume via useDraft, and transformBountyFromApi (sections -> form state; winnerCount derived from prize tiers, ISO dates trimmed for the inputs). - components/organization/bounties/new/NewBountyTab.tsx: orchestrator wiring steps + draft + per-step save, persisting ?draftId= for resume, and threading the chosen mode from ModeTab into SubmissionModelTab. Scope/Reward/Review tabs (#600) and the publish + funding flow (#601) are left as marked placeholders with their save/navigate/draftId seams in place; this satisfies the acceptance criteria (navigate, autosave, resume) without speculative publish UI that depends on the unbuilt publish hook. --- .../bounties/new/NewBountyTab.tsx | 277 ++++++++++++++++++ .../organization/bounties/new/constants.ts | 71 +++++ hooks/use-bounty-draft.ts | 183 ++++++++++++ hooks/use-bounty-steps.ts | 130 ++++++++ 4 files changed, 661 insertions(+) create mode 100644 components/organization/bounties/new/NewBountyTab.tsx create mode 100644 components/organization/bounties/new/constants.ts create mode 100644 hooks/use-bounty-draft.ts create mode 100644 hooks/use-bounty-steps.ts 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/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, + }; +}; From df74eb06c25a4d405b5c084e5969574d17a3ca73 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Thu, 18 Jun 2026 08:00:43 +0100 Subject: [PATCH 3/5] feat: implement multi-step crowdfunding campaign wizard with milestone management --- .claude/launch.json | 11 + app/(landing)/crowdfunding/[slug]/page.tsx | 507 + app/(landing)/crowdfunding/new/page.tsx | 22 + app/(landing)/crowdfunding/page.tsx | 154 + .../[slug]/components/CampaignTabs.tsx | 48 +- .../[slug]/contributions/page.tsx | 109 +- .../edit/components/BasicInfoSection.tsx | 233 - .../components/ContactSocialSection.old.tsx | 165 - .../edit/components/ContactSocialSection.tsx | 249 - .../edit/components/DetailsFundingSection.tsx | 82 - .../edit/components/MilestonesSection.tsx | 364 - .../edit/components/ProjectLinksSection.tsx | 57 - .../edit/components/RepoLinksSection.tsx | 75 - .../[slug]/edit/components/TeamSection.tsx | 243 - .../[slug]/edit/components/index.ts | 7 - .../edit/components/md-editor-custom.css | 98 - app/me/crowdfunding/[slug]/edit/page.tsx | 316 +- app/me/crowdfunding/[slug]/layout.tsx | 179 + .../milestones/[milestoneIndex]/page.tsx | 394 +- .../crowdfunding/[slug]/milestones/page.tsx | 72 +- app/me/crowdfunding/[slug]/page.tsx | 99 +- app/me/crowdfunding/new/page.tsx | 5 + app/me/crowdfunding/page.tsx | 59 +- app/me/layout.tsx | 20 - components/app-sidebar.tsx | 46 +- components/crowdfunding-table-columns.tsx | 91 +- components/crowdfunding-table-toolbar.tsx | 2 +- .../crowdfunding/CampaignStatusBanner.tsx | 397 + components/crowdfunding/ContributeSheet.tsx | 295 + .../crowdfunding/MilestoneSubmitForm.tsx | 279 + .../crowdfunding/campaign-milestones-tab.tsx | 153 - .../crowdfunding/new/NewCampaignSidebar.tsx | 176 + .../crowdfunding/new/NewCampaignWizard.tsx | 502 + .../crowdfunding/new/WizardHelpButton.tsx | 78 + components/crowdfunding/new/constants.ts | 64 + components/crowdfunding/new/fee.ts | 46 + .../crowdfunding/new/milestone-templates.ts | 216 + .../crowdfunding/new/steps/BasicsStep.tsx | 182 + .../crowdfunding/new/steps/FundingStep.tsx | 106 + .../crowdfunding/new/steps/LinksStep.tsx | 151 + .../crowdfunding/new/steps/MilestonesStep.tsx | 368 + .../crowdfunding/new/steps/ReviewStep.tsx | 224 + .../crowdfunding/new/steps/StoryStep.tsx | 38 + .../crowdfunding/new/steps/TeamStep.tsx | 191 + .../landing-page/hackathon/HackathonCard.tsx | 16 +- components/me-dashboard.tsx | 218 +- .../project-sidebar/ProjectSidebarLinks.tsx | 33 +- features/crowdfunding/api/campaign-client.ts | 212 + features/crowdfunding/api/keys.ts | 27 + features/crowdfunding/api/milestone-client.ts | 76 + features/crowdfunding/api/use-campaign.ts | 170 + features/crowdfunding/api/use-milestone.ts | 104 + features/crowdfunding/index.ts | 74 + features/crowdfunding/types.ts | 102 + features/projects/api/index.ts | 113 + features/projects/types/index.ts | 28 + lib/api/generated/schema.d.ts | 627 +- lib/api/user/earnings.ts | 26 - openapi.snapshot.json | 19258 +++++++++++++--- 59 files changed, 21387 insertions(+), 6870 deletions(-) create mode 100644 .claude/launch.json create mode 100644 app/(landing)/crowdfunding/[slug]/page.tsx create mode 100644 app/(landing)/crowdfunding/new/page.tsx create mode 100644 app/(landing)/crowdfunding/page.tsx delete mode 100644 app/me/crowdfunding/[slug]/edit/components/BasicInfoSection.tsx delete mode 100644 app/me/crowdfunding/[slug]/edit/components/ContactSocialSection.old.tsx delete mode 100644 app/me/crowdfunding/[slug]/edit/components/ContactSocialSection.tsx delete mode 100644 app/me/crowdfunding/[slug]/edit/components/DetailsFundingSection.tsx delete mode 100644 app/me/crowdfunding/[slug]/edit/components/MilestonesSection.tsx delete mode 100644 app/me/crowdfunding/[slug]/edit/components/ProjectLinksSection.tsx delete mode 100644 app/me/crowdfunding/[slug]/edit/components/RepoLinksSection.tsx delete mode 100644 app/me/crowdfunding/[slug]/edit/components/TeamSection.tsx delete mode 100644 app/me/crowdfunding/[slug]/edit/components/index.ts delete mode 100644 app/me/crowdfunding/[slug]/edit/components/md-editor-custom.css create mode 100644 app/me/crowdfunding/[slug]/layout.tsx create mode 100644 app/me/crowdfunding/new/page.tsx create mode 100644 components/crowdfunding/CampaignStatusBanner.tsx create mode 100644 components/crowdfunding/ContributeSheet.tsx create mode 100644 components/crowdfunding/MilestoneSubmitForm.tsx delete mode 100644 components/crowdfunding/campaign-milestones-tab.tsx create mode 100644 components/crowdfunding/new/NewCampaignSidebar.tsx create mode 100644 components/crowdfunding/new/NewCampaignWizard.tsx create mode 100644 components/crowdfunding/new/WizardHelpButton.tsx create mode 100644 components/crowdfunding/new/constants.ts create mode 100644 components/crowdfunding/new/fee.ts create mode 100644 components/crowdfunding/new/milestone-templates.ts create mode 100644 components/crowdfunding/new/steps/BasicsStep.tsx create mode 100644 components/crowdfunding/new/steps/FundingStep.tsx create mode 100644 components/crowdfunding/new/steps/LinksStep.tsx create mode 100644 components/crowdfunding/new/steps/MilestonesStep.tsx create mode 100644 components/crowdfunding/new/steps/ReviewStep.tsx create mode 100644 components/crowdfunding/new/steps/StoryStep.tsx create mode 100644 components/crowdfunding/new/steps/TeamStep.tsx create mode 100644 features/crowdfunding/api/campaign-client.ts create mode 100644 features/crowdfunding/api/keys.ts create mode 100644 features/crowdfunding/api/milestone-client.ts create mode 100644 features/crowdfunding/api/use-campaign.ts create mode 100644 features/crowdfunding/api/use-milestone.ts create mode 100644 features/crowdfunding/index.ts create mode 100644 features/crowdfunding/types.ts diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..cab285df --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "boundless-v1", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000 + } + ] +} diff --git a/app/(landing)/crowdfunding/[slug]/page.tsx b/app/(landing)/crowdfunding/[slug]/page.tsx new file mode 100644 index 00000000..e6b80cfe --- /dev/null +++ b/app/(landing)/crowdfunding/[slug]/page.tsx @@ -0,0 +1,507 @@ +'use client'; + +import { useState, use } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { useCampaign } from '@/features/crowdfunding'; +import type { CrowdfundingContributor } from '@/features/crowdfunding'; +import { ContributeSheet } from '@/components/crowdfunding/ContributeSheet'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + ArrowLeft, + Target, + Clock, + Users, + Github, + Globe, + Video, + ExternalLink, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface PageProps { + params: Promise<{ slug: string }>; +} + +function daysLeft(dateStr?: string): number | null { + if (!dateStr) return null; + const diff = new Date(dateStr).getTime() - Date.now(); + return Math.max(0, Math.ceil(diff / 86_400_000)); +} + +function pct(raised: number, goal: number): number { + if (!goal) return 0; + return Math.min(100, Math.round((raised / goal) * 100)); +} + +function BackerRow({ c }: { c: CrowdfundingContributor }) { + const isAnon = !c.username && !c.name; + return ( +
+
+ {c.image ? ( + {c.name + ) : ( +
+ {isAnon + ? '?' + : (c.name || c.username || 'B').charAt(0).toUpperCase()} +
+ )} +
+
+

+ {isAnon ? 'Anonymous supporter' : c.name || c.username} +

+ {c.message && ( +

{c.message}

+ )} +
+
+

+ ${c.amount.toLocaleString()} +

+

+ {new Date(c.date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + })} +

+
+
+ ); +} + +export default function PublicCampaignPage({ params }: PageProps) { + const { slug } = use(params); + const { data: campaign, isLoading } = useCampaign(slug); + const [sheetOpen, setSheetOpen] = useState(false); + + if (isLoading) { + return ( +
+ Loading... +
+ ); + } + + if (!campaign) { + return ( +
+ Campaign not found. +
+ ); + } + + const project = campaign.project; + const raised = campaign.fundingRaised ?? 0; + const goal = campaign.fundingGoal ?? 0; + const progress = pct(raised, goal); + const days = daysLeft(campaign.fundingEndDate); + const isLive = campaign.v2Status === 'FUNDING'; + const backers = campaign.contributors ?? []; + + const statusLabel: Record = { + FUNDING: 'Live', + COMPLETED: 'Completed', + VOTING: 'Community Vote', + DRAFT: 'Draft', + SUBMITTED_FOR_REVIEW: 'Under Review', + }; + + return ( +
+ {/* Back link */} +
+
+ + + All campaigns + +
+
+ + {/* Hero */} +
+ {project.banner ? ( +
+ {project.title} +
+
+ ) : ( +
+ )} +
+ +
+ {/* Title row */} +
+
+ {project.logo && ( +
+ {project.title} +
+ )} +
+

+ {project.title} +

+ {project.tagline && ( +

+ {project.tagline} +

+ )} +
+
+ {campaign.v2Status && ( + + {statusLabel[campaign.v2Status] ?? campaign.v2Status} + + )} +
+ +
+ {/* Main content */} +
+ + + {(['about', 'milestones', 'team', 'backers'] as const).map( + t => ( + + {t} + {t === 'backers' && backers.length > 0 && ( + + {backers.length} + + )} + + ) + )} + + + +
+ {project.vision || project.description || ( +

+ No description provided. +

+ )} +
+ {(project.githubUrl || + project.projectWebsite || + project.demoVideo) && ( +
+ {project.githubUrl && ( + + + GitHub + + + )} + {project.projectWebsite && ( + + + Website + + + )} + {project.demoVideo && ( + + + )} +
+ )} +
+ + + {campaign.milestones.length === 0 ? ( +

No milestones listed.

+ ) : ( +
+ {campaign.milestones.map((m, idx) => { + const statusColors: Record = { + completed: + 'text-emerald-400 bg-emerald-900/30 border-emerald-800/30', + approved: + 'text-emerald-400 bg-emerald-900/30 border-emerald-800/30', + in_review: + 'text-amber-400 bg-amber-900/30 border-amber-800/30', + submitted: + 'text-amber-400 bg-amber-900/30 border-amber-800/30', + pending: + 'text-zinc-400 bg-zinc-900/30 border-zinc-800/30', + }; + const sc = + statusColors[m.reviewStatus?.toLowerCase()] ?? + statusColors.pending; + return ( +
+
+
+

+ {idx + 1}. {m.name} +

+ {m.description && ( +

+ {m.description} +

+ )} +
+
+ {isLive && m.amount != null && ( + + ${m.amount.toLocaleString()} + + )} + {m.reviewStatus && ( + + {m.reviewStatus.replace('_', ' ')} + + )} +
+
+ {m.endDate && ( +

+ + Due{' '} + {new Date(m.endDate).toLocaleDateString('en-US', { + month: 'short', + year: 'numeric', + })} +

+ )} +
+ ); + })} +
+ )} +
+ + + {campaign.team.length === 0 ? ( +

+ No team members listed. +

+ ) : ( +
+ {campaign.team.map((m, idx) => ( +
+
+ {m.image ? ( + {m.name} + ) : ( +
+ {m.name.charAt(0).toUpperCase()} +
+ )} +
+
+

+ {m.name} +

+

{m.role}

+
+
+ ))} +
+ )} +
+ + + {backers.length === 0 ? ( +
+ +

+ No backers yet. Be the first! +

+ {isLive && ( + + )} +
+ ) : ( +
+ {backers.map((c, i) => ( + + ))} +
+ )} +
+
+
+ + {/* Sidebar */} +
+
+
+
+
+
+
+ {progress}% funded + {days !== null && {days} days left} +
+
+ +
+
+ +
+

+ ${raised.toLocaleString()} +

+

+ raised of ${goal.toLocaleString()} goal +

+
+
+
+ +
+

+ {backers.length} +

+

backers

+
+
+
+ + {isLive ? ( + + ) : ( +
+ {campaign.v2Status === 'COMPLETED' + ? 'This campaign has closed.' + : 'Contributions open when the campaign goes live.'} +
+ )} +
+ + {project.creator && ( +
+

+ Builder +

+
+
+ {project.creator.image ? ( + {project.creator.name} + ) : ( +
+ {(project.creator.name || 'B').charAt(0).toUpperCase()} +
+ )} +
+
+

+ {project.creator.name} +

+ {project.creator.username && ( +

+ @{project.creator.username} +

+ )} +
+
+
+ )} +
+
+
+ + +
+ ); +} diff --git a/app/(landing)/crowdfunding/new/page.tsx b/app/(landing)/crowdfunding/new/page.tsx new file mode 100644 index 00000000..63916177 --- /dev/null +++ b/app/(landing)/crowdfunding/new/page.tsx @@ -0,0 +1,22 @@ +import { Metadata } from 'next'; +import { AuthGuard } from '@/components/auth'; +import { Suspense } from 'react'; +import NewCampaignWizard from '@/components/crowdfunding/new/NewCampaignWizard'; + +export const metadata: Metadata = { + title: 'New Campaign | Boundless', + description: 'Create a new crowdfunding campaign on Boundless', +}; + +export default function NewCampaignPage() { + return ( + Authenticating...
} + > + Loading...
}> + + + + ); +} diff --git a/app/(landing)/crowdfunding/page.tsx b/app/(landing)/crowdfunding/page.tsx new file mode 100644 index 00000000..e9bbfd0e --- /dev/null +++ b/app/(landing)/crowdfunding/page.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import Image from 'next/image'; +import { useCampaigns } from '@/features/crowdfunding'; +import type { CrowdfundingCampaign } from '@/features/crowdfunding'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Search, Rocket, Target } from 'lucide-react'; + +function CampaignCard({ c }: { c: CrowdfundingCampaign }) { + const raised = c.fundingRaised ?? 0; + const goal = c.fundingGoal ?? 0; + const progress = goal ? Math.min(100, Math.round((raised / goal) * 100)) : 0; + const project = c.project; + + return ( + +
+ {project.banner ? ( + {project.title} + ) : ( +
+ +
+ )} +
+
+
+
+ {project.logo && ( +
+ {project.title} +
+ )} +
+

+ {project.title} +

+ {project.category && ( +

{project.category}

+ )} +
+
+ {c.v2Status === 'FUNDING' && ( + + Live + + )} +
+ + {project.tagline && ( +

+ {project.tagline} +

+ )} + +
+
+
+
+
+ + ${raised.toLocaleString()} raised + + {progress}% +
+
+
+ + ); +} + +export default function CrowdfundingListPage() { + const [search, setSearch] = useState(''); + const { data, isLoading } = useCampaigns(1, 24, { status: 'FUNDING' }); + + const campaigns = data?.data ?? []; + const filtered = campaigns.filter( + c => + !search || + c.project.title.toLowerCase().includes(search.toLowerCase()) || + c.project.tagline?.toLowerCase().includes(search.toLowerCase()) + ); + + return ( +
+
+
+

Campaigns

+

+ Back community-driven projects built by the Boundless ecosystem. +

+
+ +
+
+ + setSearch(e.target.value)} + className='border-zinc-800 bg-zinc-900 pl-9 text-white placeholder:text-zinc-600' + /> +
+
+ + {isLoading ? ( +
+ {[...Array(6)].map((_, i) => ( +
+ ))} +
+ ) : filtered.length === 0 ? ( +
+ +

+ {search + ? 'No campaigns match your search.' + : 'No live campaigns yet.'} +

+
+ ) : ( +
+ {filtered.map(c => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/app/me/crowdfunding/[slug]/components/CampaignTabs.tsx b/app/me/crowdfunding/[slug]/components/CampaignTabs.tsx index 0e2f4a6a..7ebda1d2 100644 --- a/app/me/crowdfunding/[slug]/components/CampaignTabs.tsx +++ b/app/me/crowdfunding/[slug]/components/CampaignTabs.tsx @@ -1,74 +1,36 @@ -import { Target, DollarSign, Users } from 'lucide-react'; +import { Users, MessageSquare } from 'lucide-react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { CampaignTeamTab } from '@/components/crowdfunding/campaign-team-tab'; -import { CampaignMilestonesTab } from '@/components/crowdfunding/campaign-milestones-tab'; import { CampaignCommentsTab } from '@/components/crowdfunding/campaign-comments-tab'; -import { CampaignFundingTab } from '@/components/crowdfunding/campaign-funding-tab'; import { Crowdfunding } from '@/features/projects/types'; interface CampaignTabsProps { campaign: Crowdfunding; } +// Milestones and Funding/Contributions are now top-level tabs in the campaign +// layout, so this only carries Team + Comments. export function CampaignTabs({ campaign }: CampaignTabsProps) { return ( - + Team - - - Milestones - - - - + Comments - - - Funding - - - - - - - - - ); } diff --git a/app/me/crowdfunding/[slug]/contributions/page.tsx b/app/me/crowdfunding/[slug]/contributions/page.tsx index 7f55c183..4ac9b0e7 100644 --- a/app/me/crowdfunding/[slug]/contributions/page.tsx +++ b/app/me/crowdfunding/[slug]/contributions/page.tsx @@ -1,102 +1,41 @@ 'use client'; -import { use, useEffect, useState } from 'react'; -import { getCrowdfundingProject } from '@/features/projects/api'; -import type { Crowdfunding } from '@/features/projects/types'; +import { use } from 'react'; + +import { useCampaign } from '@/features/crowdfunding'; import { ContributionsDataTable } from './contributions-data-table'; -import { ArrowLeft } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { useRouter } from 'next/navigation'; import { ContributionsMetrics } from '@/components/crowdfunding/contributions-metrics'; interface ContributionsPageProps { - params: Promise<{ - slug: string; - }>; + params: Promise<{ slug: string }>; } export default function ContributionsPage({ params }: ContributionsPageProps) { - const router = useRouter(); - const resolvedParams = use(params); - const slug = resolvedParams.slug; + const { slug } = use(params); + const { data: campaign, isLoading, error } = useCampaign(slug); - const [project, setProject] = useState(null); - const [loading, setLoading] = useState(true); + if (isLoading) { + return ( +
+
+
+ ); + } - useEffect(() => { - const fetchProject = async () => { - try { - setLoading(true); - const data = await getCrowdfundingProject(slug); - setProject(data); - } catch { - // Error handled by UI state - } finally { - setLoading(false); - } - }; + if (error || !campaign) { + return ( +
+ Failed to load contributions +
+ ); + } - fetchProject(); - }, [slug]); + const contributors = campaign.contributors ?? []; return ( -
- {/* Header */} -
- - -
-

Contributions

- {project && ( -
-

- Project:{' '} - - {project.project.title} - -

- -

- {project.contributors.length}{' '} - {project.contributors.length === 1 - ? 'Contributor' - : 'Contributors'} -

-
- )} -
-
- - {/* Table */} -
- {loading ? ( -
-
-
- ) : project ? ( -
- - -
- ) : ( -
-

Failed to load contributions

-
- )} -
+
+ +
); } diff --git a/app/me/crowdfunding/[slug]/edit/components/BasicInfoSection.tsx b/app/me/crowdfunding/[slug]/edit/components/BasicInfoSection.tsx deleted file mode 100644 index b1dfceac..00000000 --- a/app/me/crowdfunding/[slug]/edit/components/BasicInfoSection.tsx +++ /dev/null @@ -1,233 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Textarea } from '@/components/ui/textarea'; -import { Button } from '@/components/ui/button'; -import { Upload, X } from 'lucide-react'; -import Image from 'next/image'; -import { uploadService } from '@/lib/api/upload'; -import { cn } from '@/lib/utils'; - -interface BasicInfoSectionProps { - data: { - title: string; - logo: string; - vision: string; - category: string; - }; - onChange: (field: string, value: any) => void; -} - -const categories = [ - 'DeFi & Finance', - 'Gaming & Metaverse', - 'Social & Community', - 'Infrastructure & Tooling', - 'AI & Machine Learning', - 'Sustainability & Impact', - 'Other', -]; - -export function BasicInfoSection({ data, onChange }: BasicInfoSectionProps) { - const [uploading, setUploading] = useState(false); - const [isDragOver, setIsDragOver] = useState(false); - - const handleLogoUpload = async ( - event: React.ChangeEvent - ) => { - const file = event.target.files?.[0]; - if (!file) return; - - setUploading(true); - try { - const result = await uploadService.uploadSingle(file); - onChange('logo', result.data.url); - } catch { - // Error handled silently - } finally { - setUploading(false); - } - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(true); - }; - - const handleDragLeave = () => { - setIsDragOver(false); - }; - - const handleDrop = async (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(false); - - const file = e.dataTransfer.files?.[0]; - if (!file) return; - - setUploading(true); - try { - const result = await uploadService.uploadSingle(file); - onChange('logo', result.data.url); - } catch { - // Error handled silently - } finally { - setUploading(false); - } - }; - - const removeLogo = () => { - onChange('logo', ''); - }; - - return ( -
- {/* Two Column Grid Layout */} -
- {/* Left Column - Title and Logo */} -
- {/* Title */} -
- - onChange('title', e.target.value)} - placeholder='Enter project name/title' - className='bg-card text-foreground placeholder:text-muted-foreground' - /> -
- - {/* Logo */} -
- -
- {data.logo ? ( -
-
- Project logo -
- -
- ) : null} - -
-
-
- - {/* Right Column - Vision and Category */} -
- {/* Vision */} -
- -