From 8bbb6a45c8fc5f2c22c2a49d38efecd225952540 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Wed, 24 Jun 2026 22:33:23 +0100 Subject: [PATCH 1/3] feat(bounty): participant data layer (escrow client + hooks) Add the builder/participant data layer to features/bounties, mirroring the hackathon participant scope and reusing the shared escrow runner. The visible builder UI (marketplace, detail, apply/submit/withdraw, my-bounties) consumes this. - participant-escrow-client: on-chain apply/submit/withdraw-application/ withdraw-submission/contribute + participant op poll + submit-signed (the no-org-prefix /api/bounties/{id}/escrow/* routes) - participant-client: public list/detail, v2 application records (apply/edit/withdraw/me), competition join/leave - use-participant hooks: useBountiesList/useBounty/useMyBountyApplication, useApply/Edit/WithdrawApplication, useJoin/LeaveCompetition, and escrow-op hooks (useSubmitBounty/useWithdrawSubmission/useContributeToBounty/ useApplyToBountyEscrow/useWithdrawApplicationEscrow) driven through the runner, default MANAGED - use-escrow: EscrowOpScope is now organizer | participant; the runner routes op-poll/submit-signed to the participant URLs - keys/types/index: participant query keys, type aliases, public exports MyBountyApplication is hand-typed until boundless-nestjs #331 lands and codegen runs; useMyBountyActivity is deferred until the participant dashboard (#332). Closes #621 --- features/bounties/api/keys.ts | 9 + features/bounties/api/participant-client.ts | 120 +++++++++ .../bounties/api/participant-escrow-client.ts | 107 ++++++++ features/bounties/api/use-escrow.ts | 45 ++-- features/bounties/api/use-participant.ts | 236 ++++++++++++++++++ features/bounties/index.ts | 60 +++++ features/bounties/types.ts | 55 ++++ 7 files changed, 612 insertions(+), 20 deletions(-) create mode 100644 features/bounties/api/participant-client.ts create mode 100644 features/bounties/api/participant-escrow-client.ts create mode 100644 features/bounties/api/use-participant.ts diff --git a/features/bounties/api/keys.ts b/features/bounties/api/keys.ts index c0ca5a54..192ec8e3 100644 --- a/features/bounties/api/keys.ts +++ b/features/bounties/api/keys.ts @@ -10,4 +10,13 @@ export const bountyKeys = { [...bountyKeys.all, 'draft', organizationId, id] as const, escrowOp: (scope: string, opRowId: string) => [...bountyKeys.all, 'escrow-op', scope, opRowId] as const, + + // Builder / participant reads. + list: (params: Record = {}) => + [...bountyKeys.all, 'list', params] as const, + detail: (bountyId: string) => + [...bountyKeys.all, 'detail', bountyId] as const, + myApplication: (bountyId: string) => + [...bountyKeys.all, 'my-application', bountyId] as const, + myActivity: () => [...bountyKeys.all, 'my-activity'] as const, }; diff --git a/features/bounties/api/participant-client.ts b/features/bounties/api/participant-client.ts new file mode 100644 index 00000000..0e4c08d4 --- /dev/null +++ b/features/bounties/api/participant-client.ts @@ -0,0 +1,120 @@ +/** + * Builder/participant bounty REST client: the public marketplace reads and the + * v2 application-record surface (off-chain proposal carrier for the application + * entry types), plus open-competition join/leave. + * + * Several backend handlers still return the raw entity without an `@ApiOkResponse` + * (so the generated schema types them as `never`). Those are cast to the + * hand-typed `MyBountyApplication` until boundless-nestjs #331 lands and codegen + * picks up the real DTO. + */ +import { apiClient, unwrapData } from '@/lib/api'; + +import type { + BountyPublic, + BountyPublicList, + CreateBountyApplicationRequest, + EditBountyApplicationRequest, + JoinCompetitionRequest, + MyBountyApplication, +} from '../types'; + +export interface BountiesListParams { + organizationId?: string; + status?: string; + search?: string; + page?: number; + limit?: number; +} + +// ── Public marketplace ──────────────────────────────────────────────────────── + +/** Paginated public bounty list (the marketplace). */ +export const listBounties = async ( + params: BountiesListParams = {} +): Promise => + unwrapData( + await apiClient.GET('/api/bounties', { params: { query: params } }) + ); + +/** A single public bounty (the detail page). */ +export const getBounty = async (bountyId: string): Promise => + unwrapData( + await apiClient.GET('/api/bounties/{id}', { + params: { path: { id: bountyId } }, + }) + ); + +// ── v2 application records (application entry types) ────────────────────────── + +/** The caller's own application for a bounty, or null when they have not applied. */ +export const getMyBountyApplication = async ( + bountyId: string +): Promise => + (unwrapData( + await apiClient.GET('/api/bounties/{bountyId}/v2/applications/me', { + params: { path: { bountyId } }, + }) + ) as unknown as MyBountyApplication | null) ?? null; + +/** Submit an application (light / full proposal) for an application-mode bounty. */ +export const applyToBounty = async ( + bountyId: string, + body: CreateBountyApplicationRequest +): Promise => + unwrapData( + await apiClient.POST('/api/bounties/{bountyId}/v2/applications', { + params: { path: { bountyId } }, + body, + }) + ) as unknown as MyBountyApplication; + +/** Edit a SUBMITTED application (before shortlist). */ +export const editBountyApplication = async ( + bountyId: string, + appId: string, + body: EditBountyApplicationRequest +): Promise => + unwrapData( + await apiClient.PATCH('/api/bounties/{bountyId}/v2/applications/{appId}', { + params: { path: { bountyId, appId } }, + body, + }) + ) as unknown as MyBountyApplication; + +/** Withdraw a SUBMITTED application (the application record; status -> WITHDRAWN). */ +export const withdrawBountyApplication = async ( + bountyId: string, + appId: string +): Promise => + unwrapData( + await apiClient.DELETE('/api/bounties/{bountyId}/v2/applications/{appId}', { + params: { path: { bountyId, appId } }, + }) + ) as unknown as MyBountyApplication; + +// ── Open competition join / leave ───────────────────────────────────────────── + +/** Join an open competition directly (no application step). */ +export const joinCompetition = async ( + bountyId: string, + body: JoinCompetitionRequest +): Promise => + unwrapData( + await apiClient.POST('/api/bounties/{bountyId}/v2/competition/join', { + params: { path: { bountyId } }, + body, + }) + ) as unknown as MyBountyApplication; + +/** Leave an open competition the caller joined. */ +export const leaveCompetition = async ( + bountyId: string, + body: JoinCompetitionRequest +): Promise => + unwrapData( + await apiClient.DELETE('/api/bounties/{bountyId}/v2/competition/join', { + params: { path: { bountyId } }, + body, + }) + ) as unknown as MyBountyApplication; diff --git a/features/bounties/api/participant-escrow-client.ts b/features/bounties/api/participant-escrow-client.ts new file mode 100644 index 00000000..44fc9306 --- /dev/null +++ b/features/bounties/api/participant-escrow-client.ts @@ -0,0 +1,107 @@ +/** + * Builder/participant bounty escrow client (boundless-events contract), on the + * typed openapi-fetch client. Counterpart to the organizer `escrow-client.ts`: + * these are the participant-scoped, no-org-prefix routes (`/api/bounties/{id}/ + * escrow/...`) the builder lifecycle drives through the shared escrow runner. + * + * Like the organizer ops, each builds an EscrowOp: + * 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. + */ +import { apiClient, unwrapData } from '@/lib/api'; + +import type { + ApplyBountyRequest, + BountyEscrowOpResponse, + ContributeBountyRequest, + SubmitBountyRequest, + SubmitSignedXdrRequest, + WithdrawApplicationRequest, + WithdrawSubmissionRequest, +} from '../types'; + +/** Apply to a bounty on-chain (APPLY_TO_BOUNTY; charges application credits). */ +export const applyToBountyEscrow = async ( + bountyId: string, + body: ApplyBountyRequest +): Promise => + unwrapData( + await apiClient.POST('/api/bounties/{id}/escrow/apply', { + params: { path: { id: bountyId } }, + body, + }) + ); + +/** Withdraw a bounty application (refunds half the application credits). */ +export const withdrawBountyApplicationEscrow = async ( + bountyId: string, + body: WithdrawApplicationRequest +): Promise => + unwrapData( + await apiClient.POST('/api/bounties/{id}/escrow/withdraw-application', { + params: { path: { id: bountyId } }, + body, + }) + ); + +/** Anchor a work submission on-chain (SUBMIT; requires an active application). */ +export const submitBountyWorkEscrow = async ( + bountyId: string, + body: SubmitBountyRequest +): Promise => + unwrapData( + await apiClient.POST('/api/bounties/{id}/escrow/submit', { + params: { path: { id: bountyId } }, + body, + }) + ); + +/** Withdraw a submission anchor. */ +export const withdrawBountySubmissionEscrow = async ( + bountyId: string, + body: WithdrawSubmissionRequest +): Promise => + unwrapData( + await apiClient.POST('/api/bounties/{id}/escrow/withdraw-submission', { + params: { path: { id: bountyId } }, + body, + }) + ); + +/** Contribute funds to a bounty's escrow pool (ADD_FUNDS; 10 USDC minimum). */ +export const contributeToBountyEscrow = async ( + bountyId: string, + body: ContributeBountyRequest +): Promise => + unwrapData( + await apiClient.POST('/api/bounties/{id}/escrow/contribute', { + params: { path: { id: bountyId } }, + body, + }) + ); + +/** Submit a wallet-signed XDR for a participant op (EXTERNAL path). */ +export const submitSignedParticipantBounty = async ( + bountyId: string, + opRowId: string, + body: SubmitSignedXdrRequest +): Promise => + unwrapData( + await apiClient.POST( + '/api/bounties/{id}/escrow/ops/{opRowId}/submit-signed', + { params: { path: { id: bountyId, opRowId } }, body } + ) + ); + +/** Poll the current state of a participant escrow op. */ +export const getParticipantBountyOp = async ( + bountyId: string, + opRowId: string +): Promise => + unwrapData( + await apiClient.GET('/api/bounties/{id}/escrow/ops/{opRowId}', { + params: { path: { id: bountyId, opRowId } }, + }) + ); diff --git a/features/bounties/api/use-escrow.ts b/features/bounties/api/use-escrow.ts index ad42f4c3..98067fb7 100644 --- a/features/bounties/api/use-escrow.ts +++ b/features/bounties/api/use-escrow.ts @@ -32,6 +32,10 @@ import { selectBountyWinners, submitSignedBountyEscrow, } from './escrow-client'; +import { + getParticipantBountyOp, + submitSignedParticipantBounty, +} from './participant-escrow-client'; import { isTerminalEscrowStatus, type BountyEscrowOpResponse, @@ -41,19 +45,19 @@ import { type SelectBountyWinnersRequest, } from '../types'; -// ─── Scope: organizer (org + bounty) ───────────────────────────────────────── +// ─── Scope: organizer (org + bounty) or participant (bounty-only) ───────────── -export type EscrowOpScope = { - kind: 'organizer'; - organizationId: string; - bountyId: string; -}; +export type EscrowOpScope = + | { kind: 'organizer'; organizationId: string; bountyId: string } + | { kind: 'participant'; bountyId: string }; function getOpForScope( scope: EscrowOpScope, opRowId: string ): Promise { - return getBountyEscrowOp(scope.organizationId, scope.bountyId, opRowId); + return scope.kind === 'participant' + ? getParticipantBountyOp(scope.bountyId, opRowId) + : getBountyEscrowOp(scope.organizationId, scope.bountyId, opRowId); } function submitSignedForScope( @@ -61,22 +65,23 @@ function submitSignedForScope( opRowId: string, signedXdr: string ): Promise { - return submitSignedBountyEscrow( - scope.organizationId, - scope.bountyId, - opRowId, - { signedXdr } - ); + return scope.kind === 'participant' + ? submitSignedParticipantBounty(scope.bountyId, opRowId, { signedXdr }) + : submitSignedBountyEscrow(scope.organizationId, scope.bountyId, opRowId, { + signedXdr, + }); } function escrowOpKey(scope: EscrowOpScope, opRowId: string): QueryKey { - return [ - 'bounty-escrow-op', - 'organizer', - scope.organizationId, - scope.bountyId, - opRowId, - ]; + return scope.kind === 'participant' + ? ['bounty-escrow-op', 'participant', scope.bountyId, opRowId] + : [ + 'bounty-escrow-op', + 'organizer', + scope.organizationId, + scope.bountyId, + opRowId, + ]; } // ─── 1. Polling primitive ──────────────────────────────────────────────────── diff --git a/features/bounties/api/use-participant.ts b/features/bounties/api/use-participant.ts new file mode 100644 index 00000000..f3ebcad6 --- /dev/null +++ b/features/bounties/api/use-participant.ts @@ -0,0 +1,236 @@ +'use client'; + +/** + * React Query hooks for the bounty builder/participant lifecycle (boundless #621). + * + * reads — useBountiesList / useBounty (public), useMyBountyApplication. + * applications — useApplyToBounty / useEditApplication / useWithdrawApplication + * / useJoinCompetition / useLeaveCompetition (v2 records). + * escrow ops — useSubmitBounty / useWithdrawSubmission / useContributeToBounty + * / useApplyToBountyEscrow / useWithdrawApplicationEscrow, each + * driven through the shared {@link useEscrowOpRunner} on the + * participant scope. Funding defaults to MANAGED (the backend + * signs + submits with the caller's platform-held wallet); pass + * a `signXdr` to take the EXTERNAL connected-wallet path. + * + * `useMyBountyActivity` (cross-bounty applications + submissions) waits on the + * backend participant dashboard (boundless-nestjs #332); it is intentionally not + * implemented here yet. + */ +import { useCallback } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { useEscrowOpRunner, type SignXdrFn } from './use-escrow'; +import { bountyKeys } from './keys'; +import { + applyToBountyEscrow, + contributeToBountyEscrow, + submitBountyWorkEscrow, + withdrawBountyApplicationEscrow, + withdrawBountySubmissionEscrow, +} from './participant-escrow-client'; +import { + applyToBounty, + editBountyApplication, + getBounty, + getMyBountyApplication, + joinCompetition, + leaveCompetition, + listBounties, + withdrawBountyApplication, + type BountiesListParams, +} from './participant-client'; +import type { + ApplyBountyRequest, + ContributeBountyRequest, + CreateBountyApplicationRequest, + EditBountyApplicationRequest, + FundingMode, + JoinCompetitionRequest, + SubmitBountyRequest, + WithdrawApplicationRequest, + WithdrawSubmissionRequest, +} from '../types'; + +// ── Reads ───────────────────────────────────────────────────────────────────── + +/** Paginated public marketplace list. */ +export function useBountiesList(params: BountiesListParams = {}) { + return useQuery({ + queryKey: bountyKeys.list(params as Record), + queryFn: () => listBounties(params), + }); +} + +/** A single public bounty (detail page). */ +export function useBounty(bountyId: string | undefined) { + return useQuery({ + queryKey: bountyKeys.detail(bountyId ?? ''), + queryFn: () => getBounty(bountyId as string), + enabled: !!bountyId, + }); +} + +/** The caller's own application for a bounty (drives the detail CTA). */ +export function useMyBountyApplication(bountyId: string | undefined) { + return useQuery({ + queryKey: bountyKeys.myApplication(bountyId ?? ''), + queryFn: () => getMyBountyApplication(bountyId as string), + enabled: !!bountyId, + }); +} + +// ── Application records (v2) ────────────────────────────────────────────────── + +/** Submit an application (light/full proposal) for an application-mode bounty. */ +export function useApplyToBounty(bountyId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: CreateBountyApplicationRequest) => + applyToBounty(bountyId, body), + onSuccess: () => + qc.invalidateQueries({ queryKey: bountyKeys.myApplication(bountyId) }), + }); +} + +/** Edit a SUBMITTED application (before shortlist). */ +export function useEditApplication(bountyId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (vars: { appId: string; body: EditBountyApplicationRequest }) => + editBountyApplication(bountyId, vars.appId, vars.body), + onSuccess: () => + qc.invalidateQueries({ queryKey: bountyKeys.myApplication(bountyId) }), + }); +} + +/** Withdraw a SUBMITTED application record (status -> WITHDRAWN). */ +export function useWithdrawApplication(bountyId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (appId: string) => withdrawBountyApplication(bountyId, appId), + onSuccess: () => + qc.invalidateQueries({ queryKey: bountyKeys.myApplication(bountyId) }), + }); +} + +/** Join an open competition directly (no application step). */ +export function useJoinCompetition(bountyId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: JoinCompetitionRequest) => + joinCompetition(bountyId, body), + onSuccess: () => + qc.invalidateQueries({ queryKey: bountyKeys.myApplication(bountyId) }), + }); +} + +/** Leave an open competition the caller joined. */ +export function useLeaveCompetition(bountyId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: JoinCompetitionRequest) => + leaveCompetition(bountyId, body), + onSuccess: () => + qc.invalidateQueries({ queryKey: bountyKeys.myApplication(bountyId) }), + }); +} + +// ── Escrow ops (participant scope, default MANAGED) ─────────────────────────── + +/** A participant op body with funding mode optional (defaults to MANAGED). */ +type WithManagedDefault = Omit< + T, + 'fundingMode' +> & { fundingMode?: FundingMode }; + +const withManaged = ( + body: WithManagedDefault +): T => ({ fundingMode: 'MANAGED', ...body }) as T; + +interface ParticipantOpOptions { + /** Sign the unsigned XDR for the EXTERNAL path. Omit for MANAGED. */ + signXdr?: SignXdrFn; +} + +/** + * Build a participant escrow-op hook: a runner on the participant scope plus a + * `run(body)` that starts the op (funding defaults to MANAGED). Returns the full + * runner surface (phase/status/error/txHash) for the calling UI. + */ +function useParticipantOp( + bountyId: string, + start: (id: string, body: T) => ReturnType, + options: ParticipantOpOptions = {} +) { + const runner = useEscrowOpRunner( + { kind: 'participant', bountyId }, + options.signXdr ? { signXdr: options.signXdr } : undefined + ); + const run = useCallback( + (body: WithManagedDefault) => + runner.run(() => start(bountyId, withManaged(body))), + [runner, bountyId, start] + ); + return { ...runner, run }; +} + +/** Anchor a work submission on-chain (SUBMIT). */ +export function useSubmitBounty( + bountyId: string, + options?: ParticipantOpOptions +) { + return useParticipantOp( + bountyId, + submitBountyWorkEscrow, + options + ); +} + +/** Withdraw a submission anchor. */ +export function useWithdrawSubmission( + bountyId: string, + options?: ParticipantOpOptions +) { + return useParticipantOp( + bountyId, + withdrawBountySubmissionEscrow, + options + ); +} + +/** Apply to a bounty on-chain (APPLY_TO_BOUNTY; open modes). */ +export function useApplyToBountyEscrow( + bountyId: string, + options?: ParticipantOpOptions +) { + return useParticipantOp( + bountyId, + applyToBountyEscrow, + options + ); +} + +/** Withdraw an on-chain application (open modes). */ +export function useWithdrawApplicationEscrow( + bountyId: string, + options?: ParticipantOpOptions +) { + return useParticipantOp( + bountyId, + withdrawBountyApplicationEscrow, + options + ); +} + +/** Contribute funds to a bounty's escrow pool (ADD_FUNDS). */ +export function useContributeToBounty( + bountyId: string, + options?: ParticipantOpOptions +) { + return useParticipantOp( + bountyId, + contributeToBountyEscrow, + options + ); +} diff --git a/features/bounties/index.ts b/features/bounties/index.ts index ce57379e..2273615b 100644 --- a/features/bounties/index.ts +++ b/features/bounties/index.ts @@ -86,3 +86,63 @@ export type { UseEscrowOpRunnerOptions, EscrowOpRunner, } from './api/use-escrow'; + +// ── Builder / participant data layer (#621) ────────────────────────────────── + +// Participant types (aliased from the generated schema; MyBountyApplication is +// hand-typed until boundless-nestjs #331 lands and codegen runs). +export type { + BountyPublic, + BountyPublicList, + MyBountyApplication, + BountyApplicationStatus, + ApplyBountyRequest, + SubmitBountyRequest, + WithdrawApplicationRequest, + WithdrawSubmissionRequest, + ContributeBountyRequest, + CreateBountyApplicationRequest, + EditBountyApplicationRequest, + JoinCompetitionRequest, +} from './types'; + +// Participant REST client (public reads + v2 application records + competition). +export { + listBounties, + getBounty, + getMyBountyApplication, + applyToBounty, + editBountyApplication, + withdrawBountyApplication, + joinCompetition, + leaveCompetition, +} from './api/participant-client'; +export type { BountiesListParams } from './api/participant-client'; + +// Participant escrow client (on-chain apply/submit/withdraw/contribute). +export { + applyToBountyEscrow, + withdrawBountyApplicationEscrow, + submitBountyWorkEscrow, + withdrawBountySubmissionEscrow, + contributeToBountyEscrow, + getParticipantBountyOp, + submitSignedParticipantBounty, +} from './api/participant-escrow-client'; + +// Participant hooks (React Query). +export { + useBountiesList, + useBounty, + useMyBountyApplication, + useApplyToBounty, + useEditApplication, + useWithdrawApplication, + useJoinCompetition, + useLeaveCompetition, + useSubmitBounty, + useWithdrawSubmission, + useApplyToBountyEscrow, + useWithdrawApplicationEscrow, + useContributeToBounty, +} from './api/use-participant'; diff --git a/features/bounties/types.ts b/features/bounties/types.ts index 132d0aa2..0727334c 100644 --- a/features/bounties/types.ts +++ b/features/bounties/types.ts @@ -74,3 +74,58 @@ export const TERMINAL_ESCROW_STATUSES: readonly EscrowOpStatus[] = [ export const isTerminalEscrowStatus = (status: EscrowOpStatus): boolean => TERMINAL_ESCROW_STATUSES.includes(status); + +// ── Builder / participant ───────────────────────────────────────────────────── + +/** Public marketplace bounty (list row + detail share this shape). */ +export type BountyPublic = Schemas['BountyPublicDto']; +/** Paginated marketplace list response. */ +export type BountyPublicList = Schemas['BountyPublicListDto']; + +// Participant escrow op request bodies (on-chain apply/submit/withdraw/contribute). +export type ApplyBountyRequest = Schemas['ApplyBountyDto']; +export type SubmitBountyRequest = Schemas['SubmitBountyDto']; +export type WithdrawApplicationRequest = Schemas['WithdrawApplicationDto']; +export type WithdrawSubmissionRequest = Schemas['WithdrawSubmissionDto']; +export type ContributeBountyRequest = Schemas['ContributeBountyDto']; + +// v2 application records (off-chain proposal carrier for application modes). +export type CreateBountyApplicationRequest = + Schemas['CreateBountyApplicationDto']; +export type EditBountyApplicationRequest = Schemas['EditBountyApplicationDto']; +export type JoinCompetitionRequest = Schemas['JoinCompetitionDto']; + +/** + * The caller's own application for a bounty (GET …/v2/applications/me). + * + * The backend `BountyApplicationResponseDto` (boundless-nestjs #331) is not yet + * in the generated schema, so this is hand-typed to its shape. Replace with + * `Schemas['BountyApplicationResponseDto']` after that lands and codegen runs. + */ +export type BountyApplicationStatus = + | 'SUBMITTED' + | 'SHORTLISTED' + | 'SELECTED' + | 'DECLINED' + | 'WITHDRAWN'; + +export interface MyBountyApplication { + id: string; + bountyId: string; + applicantAddress: string; + status: string; + applicationStatus: BountyApplicationStatus | null; + proposalShort: string | null; + proposalFull: string | null; + portfolioLinks: string[]; + estimatedDays: number | null; + qualifications: string | null; + videoIntroUrl: string | null; + declineReason: string | null; + shortlistedAt: string | null; + selectedAt: string | null; + declinedAt: string | null; + withdrawnAt: string | null; + createdAt: string; + updatedAt: string; +} From 98593296460db8cd7e5227d20dbff4e53a5eaf93 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Wed, 24 Jun 2026 22:47:39 +0100 Subject: [PATCH 2/3] feat(bounty): public bounty marketplace (discover/list) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the /coming-soon placeholder at /bounties with a real public marketplace, mirroring the hackathon browse and wired to the participant data layer (#621). - route app/(landing)/bounties/page.tsx + bounties metadata entry - BountiesPage: search + status + mode filters, infinite scroll, loading/empty/error states - BountyCard: mode badge (plain label), status, reward, organization, application-window date; links to /bounties/[id] - BountiesFiltersHeader + colocated useInfiniteBounties (infinite query over listBounties) Status + search filter server-side; mode narrows client-side (the public list endpoint doesn't filter on mode). Category filter and a card submission deadline are omitted because BountyPublicDto exposes neither — small backend follow-ups. Closes #622 --- app/(landing)/bounties/page.tsx | 16 +- .../marketplace/BountiesFiltersHeader.tsx | 106 ++++++++++++ .../bounties/marketplace/BountiesPage.tsx | 151 ++++++++++++++++++ .../bounties/marketplace/BountyCard.tsx | 96 +++++++++++ .../marketplace/use-bounties-marketplace.ts | 46 ++++++ lib/metadata.ts | 8 + 6 files changed, 414 insertions(+), 9 deletions(-) create mode 100644 components/bounties/marketplace/BountiesFiltersHeader.tsx create mode 100644 components/bounties/marketplace/BountiesPage.tsx create mode 100644 components/bounties/marketplace/BountyCard.tsx create mode 100644 components/bounties/marketplace/use-bounties-marketplace.ts diff --git a/app/(landing)/bounties/page.tsx b/app/(landing)/bounties/page.tsx index 7b208334..7b218aaa 100644 --- a/app/(landing)/bounties/page.tsx +++ b/app/(landing)/bounties/page.tsx @@ -1,16 +1,14 @@ -import { redirect } from 'next/navigation'; import { Metadata } from 'next'; + import { generatePageMetadata } from '@/lib/metadata'; +import BountiesPage from '@/components/bounties/marketplace/BountiesPage'; -export const metadata: Metadata = generatePageMetadata('grants'); +export const metadata: Metadata = generatePageMetadata('bounties'); -const GrantPage = () => { - redirect('/coming-soon'); +export default function BountiesPageRoute() { return ( -
- Bounties Page +
+
); -}; - -export default GrantPage; +} diff --git a/components/bounties/marketplace/BountiesFiltersHeader.tsx b/components/bounties/marketplace/BountiesFiltersHeader.tsx new file mode 100644 index 00000000..bc11f96f --- /dev/null +++ b/components/bounties/marketplace/BountiesFiltersHeader.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { Search } from 'lucide-react'; + +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +export const STATUS_OPTIONS = [ + { value: 'all', label: 'All statuses' }, + { value: 'open', label: 'Open' }, + { value: 'in_progress', label: 'In progress' }, + { value: 'completed', label: 'Completed' }, + { value: 'cancelled', label: 'Cancelled' }, +] as const; + +export const MODE_OPTIONS = [ + { value: 'all', label: 'All modes' }, + { value: 'single_claim', label: 'Single claim' }, + { value: 'competition', label: 'Competition' }, + { value: 'application', label: 'Application' }, +] as const; + +export type ModeFilter = (typeof MODE_OPTIONS)[number]['value']; + +interface BountiesFiltersHeaderProps { + search: string; + status: string; + mode: ModeFilter; + totalCount: number; + onSearch: (value: string) => void; + onStatus: (value: string) => void; + onMode: (value: ModeFilter) => void; +} + +export default function BountiesFiltersHeader({ + search, + status, + mode, + totalCount, + onSearch, + onStatus, + onMode, +}: BountiesFiltersHeaderProps) { + return ( +
+
+
+

+ Bounties +

+

+ Discover open bounties and start earning. +

+
+ + {totalCount} bount{totalCount === 1 ? 'y' : 'ies'} + +
+ +
+
+ + onSearch(e.target.value)} + className='focus:border-primary h-10 rounded-xl border-zinc-800/50 bg-zinc-900/30 pl-10 text-sm text-white placeholder:text-zinc-500 hover:border-zinc-700' + /> +
+ + + + +
+
+ ); +} diff --git a/components/bounties/marketplace/BountiesPage.tsx b/components/bounties/marketplace/BountiesPage.tsx new file mode 100644 index 00000000..200997da --- /dev/null +++ b/components/bounties/marketplace/BountiesPage.tsx @@ -0,0 +1,151 @@ +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; + +import EmptyState from '@/components/EmptyState'; +import LoadingSpinner from '@/components/LoadingSpinner'; +import { BoundlessButton } from '@/components/buttons'; +import { useInfiniteScroll } from '@/hooks/use-infinite-scroll'; +import type { BountyPublic } from '@/features/bounties'; +import { BountyCard } from './BountyCard'; +import BountiesFiltersHeader, { + type ModeFilter, +} from './BountiesFiltersHeader'; +import { useInfiniteBounties } from './use-bounties-marketplace'; + +/** Client-side mode narrowing (the public list endpoint doesn't filter on mode). */ +function matchesMode(b: BountyPublic, mode: ModeFilter): boolean { + switch (mode) { + case 'single_claim': + return b.claimType === 'SINGLE_CLAIM'; + case 'competition': + return b.claimType === 'COMPETITION'; + case 'application': + return ( + b.entryType === 'APPLICATION_LIGHT' || + b.entryType === 'APPLICATION_FULL' + ); + default: + return true; + } +} + +export default function BountiesPage() { + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [status, setStatus] = useState('all'); + const [mode, setMode] = useState('all'); + + // Debounce the search so we don't refetch on every keystroke. + useEffect(() => { + const t = setTimeout(() => setDebouncedSearch(search.trim()), 300); + return () => clearTimeout(t); + }, [search]); + + const { + bounties, + totalCount, + loading, + loadingMore, + error, + hasMore, + loadMore, + } = useInfiniteBounties({ + status: status === 'all' ? undefined : status, + search: debouncedSearch || undefined, + }); + + const visible = useMemo( + () => bounties.filter(b => matchesMode(b, mode)), + [bounties, mode] + ); + + const [sentinel, setSentinel] = useState(null); + useInfiniteScroll(sentinel, { + onLoadMore: loadMore, + hasMore: !!hasMore, + loading: loadingMore, + rootMargin: '0px 0px 1200px 0px', + }); + + const hasFilters = !!debouncedSearch || status !== 'all' || mode !== 'all'; + const clearFilters = () => { + setSearch(''); + setStatus('all'); + setMode('all'); + }; + + return ( +
+ + + {loading && ( +
+ +
+ )} + + {!loading && error && ( +
+ +
+ )} + + {!loading && !error && visible.length === 0 && ( +
+ + {hasFilters && ( +
+ + Clear filters + +
+ )} +
+ )} + + {!loading && !error && visible.length > 0 && ( + <> +
+ {visible.map(b => ( + + ))} +
+ +
+ + {loadingMore && ( +
+ + Loading more... +
+ )} + + )} +
+ ); +} diff --git a/components/bounties/marketplace/BountyCard.tsx b/components/bounties/marketplace/BountyCard.tsx new file mode 100644 index 00000000..939918f5 --- /dev/null +++ b/components/bounties/marketplace/BountyCard.tsx @@ -0,0 +1,96 @@ +'use client'; + +import Link from 'next/link'; +import { Calendar, Target } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { computeBountyModeLabel } from '@/components/organization/bounties/new/tabs/schemas/modeSchema'; +import type { BountyPublic } from '@/features/bounties'; + +/** Plain-language mode label (single claim / competition / application). */ +function modeLabel(b: BountyPublic): string { + if (b.entryType && b.claimType) { + return computeBountyModeLabel(b.entryType, b.claimType); + } + return 'Bounty'; +} + +const STATUS_CLASS: Record = { + open: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400', + in_progress: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400', + completed: 'border-primary/30 bg-primary/10 text-primary', + cancelled: 'border-zinc-700 bg-zinc-800/60 text-zinc-300', +}; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +export function BountyCard({ bounty }: { bounty: BountyPublic }) { + const reward = bounty.rewardAmount > 0; + const statusClass = + STATUS_CLASS[bounty.status] ?? + 'border-zinc-700 bg-zinc-800/60 text-zinc-300'; + + return ( + +
+ + {modeLabel(bounty)} + + + {bounty.status.replace(/_/g, ' ')} + +
+ +

+ {bounty.title} +

+ {bounty.description && ( +

+ {bounty.description} +

+ )} + + {bounty.applicationWindowCloseAt && ( +

+ + Applications close {formatDate(bounty.applicationWindowCloseAt)} +

+ )} + +
+
+
+ +
+
+

+ {reward + ? `${bounty.rewardAmount.toLocaleString()} ${bounty.rewardCurrency}` + : 'No reward set'} +

+

reward

+
+
+ + {bounty.organization.name} + +
+ + ); +} diff --git a/components/bounties/marketplace/use-bounties-marketplace.ts b/components/bounties/marketplace/use-bounties-marketplace.ts new file mode 100644 index 00000000..48ec43d3 --- /dev/null +++ b/components/bounties/marketplace/use-bounties-marketplace.ts @@ -0,0 +1,46 @@ +'use client'; + +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { listBounties, bountyKeys } from '@/features/bounties'; + +const PAGE_SIZE = 12; + +export interface MarketplaceFilters { + /** Server-side: bounty lifecycle status (e.g. 'open'). */ + status?: string; + /** Server-side: free-text title/description search. */ + search?: string; +} + +/** + * Infinite (page-accumulating) public bounty list for the marketplace. Wraps the + * `listBounties` client; status + search are applied server-side, pagination via + * `getNextPageParam` off the response total. Mode/category narrowing is done + * client-side in the page (the public list endpoint doesn't filter on them). + */ +export function useInfiniteBounties(filters: MarketplaceFilters) { + const query = useInfiniteQuery({ + queryKey: bountyKeys.list({ ...filters, infinite: true }), + queryFn: ({ pageParam }) => + listBounties({ ...filters, page: pageParam, limit: PAGE_SIZE }), + initialPageParam: 1, + getNextPageParam: (lastPage, allPages) => { + const loaded = allPages.reduce((n, p) => n + p.bounties.length, 0); + return loaded < lastPage.total ? allPages.length + 1 : undefined; + }, + }); + + const bounties = query.data?.pages.flatMap(p => p.bounties) ?? []; + const totalCount = query.data?.pages[0]?.total ?? 0; + + return { + bounties, + totalCount, + loading: query.isLoading, + loadingMore: query.isFetchingNextPage, + error: query.error ? (query.error as Error).message : null, + hasMore: query.hasNextPage, + loadMore: query.fetchNextPage, + }; +} diff --git a/lib/metadata.ts b/lib/metadata.ts index c38c89ff..937dfb72 100644 --- a/lib/metadata.ts +++ b/lib/metadata.ts @@ -108,6 +108,14 @@ export const pageMetadata: Record = { ogImage: 'https://res.cloudinary.com/danuy5rqb/image/upload/v1759143589/bondless-og-image_jufgnu.png', }, + bounties: { + title: 'Bounties - Boundless', + description: + 'Discover open bounties on Boundless. Pick up work, ship it, and earn on-chain rewards.', + keywords: ['bounties', 'rewards', 'open source', 'earn', 'contribute'], + ogImage: + 'https://res.cloudinary.com/danuy5rqb/image/upload/v1759143589/bondless-og-image_jufgnu.png', + }, hackathons: { title: 'Hackathons - Boundless', description: From e7367476a566b1993f32321fa67716d5b45c6a4a Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Wed, 24 Jun 2026 23:22:51 +0100 Subject: [PATCH 3/3] feat(bounty): bounty detail page (/bounties/[id]) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The builder hub: shows the bounty, the caller's status, and a mode-aware entry CTA. Wired to the participant data layer (#621). - route app/(landing)/bounties/[id]/page.tsx - BountyDetail: markdown description, prize tiers, mode/status badges, organization, reward pool + eligibility sidebar (reputation minimum / application window / max applicants / shortlist); loading + not-found - BountyEntryCta: mode-aware CTA — Claim (open+single), Join (open+ competition), Apply (application). Gated/explained when not accepting, the application window closed, or already applied; shows the caller's application status via useMyBountyApplication The actual entry execution (forms / on-chain op) ships in #624; the CTA is gated + labeled and hands off to it. applicationCreditCost is not in BountyPublicDto so it's omitted from the eligibility panel. Closes #623 --- app/(landing)/bounties/[id]/page.tsx | 22 ++ components/bounties/detail/BountyDetail.tsx | 231 ++++++++++++++++++ components/bounties/detail/BountyEntryCta.tsx | 123 ++++++++++ 3 files changed, 376 insertions(+) create mode 100644 app/(landing)/bounties/[id]/page.tsx create mode 100644 components/bounties/detail/BountyDetail.tsx create mode 100644 components/bounties/detail/BountyEntryCta.tsx diff --git a/app/(landing)/bounties/[id]/page.tsx b/app/(landing)/bounties/[id]/page.tsx new file mode 100644 index 00000000..31a57723 --- /dev/null +++ b/app/(landing)/bounties/[id]/page.tsx @@ -0,0 +1,22 @@ +import { Metadata } from 'next'; + +import { generatePageMetadata } from '@/lib/metadata'; +import BountyDetail from '@/components/bounties/detail/BountyDetail'; + +export const metadata: Metadata = generatePageMetadata('bounties'); + +interface BountyDetailPageProps { + params: Promise<{ id: string }>; +} + +export default async function BountyDetailPageRoute({ + params, +}: BountyDetailPageProps) { + const { id } = await params; + + return ( +
+ +
+ ); +} diff --git a/components/bounties/detail/BountyDetail.tsx b/components/bounties/detail/BountyDetail.tsx new file mode 100644 index 00000000..4da15dd8 --- /dev/null +++ b/components/bounties/detail/BountyDetail.tsx @@ -0,0 +1,231 @@ +'use client'; + +import React from 'react'; +import dynamic from 'next/dynamic'; +import Link from 'next/link'; +import { + ArrowLeft, + Award, + Calendar, + Loader2, + Target, + Users, +} from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import EmptyState from '@/components/EmptyState'; +import { computeBountyModeLabel } from '@/components/organization/bounties/new/tabs/schemas/modeSchema'; +import { useBounty, useMyBountyApplication } from '@/features/bounties'; +import { BountyEntryCta } from './BountyEntryCta'; + +// Markdown renderer (matches the wizard's editor package). +const Markdown = dynamic( + () => import('@uiw/react-md-editor').then(mod => mod.default.Markdown), + { ssr: false } +); + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +const ordinal = (n: number): string => { + const s = ['th', 'st', 'nd', 'rd']; + const v = n % 100; + return `${n}${s[(v - 20) % 10] ?? s[v] ?? s[0]}`; +}; + +export default function BountyDetail({ id }: { id: string }) { + const { data: bounty, isLoading, error } = useBounty(id); + const { data: myApplication } = useMyBountyApplication(id); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error || !bounty) { + return ( +
+ +
+ + Back to bounties + +
+
+ ); + } + + const modeLabel = + bounty.entryType && bounty.claimType + ? computeBountyModeLabel(bounty.entryType, bounty.claimType) + : 'Bounty'; + const currency = bounty.rewardCurrency; + + return ( +
+ + + Back to bounties + + +
+ {/* Main */} +
+
+ + {modeLabel} + + + {bounty.status.replace(/_/g, ' ')} + +
+ +

+ {bounty.title} +

+

+ by {bounty.organization.name} +

+ + {/* Description (markdown) */} +
+ +
+ + {/* Prize tiers */} + {bounty.prizeTiers.length > 0 && ( +
+

+ + Prize tiers +

+
+ {bounty.prizeTiers + .slice() + .sort((a, b) => a.position - b.position) + .map(tier => ( +
+ + {ordinal(tier.position)} place + + + {Number(tier.amount).toLocaleString()} {currency} + +
+ ))} +
+
+ )} +
+ + {/* Sidebar */} + +
+
+ ); +} + +function Row({ + label, + value, + icon, +}: { + label: string; + value: string; + icon?: React.ReactNode; +}) { + return ( +
+
+ {icon} + {label} +
+
{value}
+
+ ); +} diff --git a/components/bounties/detail/BountyEntryCta.tsx b/components/bounties/detail/BountyEntryCta.tsx new file mode 100644 index 00000000..688a2bae --- /dev/null +++ b/components/bounties/detail/BountyEntryCta.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { toast } from 'sonner'; +import { CheckCircle2, Lock } from 'lucide-react'; + +import { BoundlessButton } from '@/components/buttons'; +import { Badge } from '@/components/ui/badge'; +import type { BountyPublic, MyBountyApplication } from '@/features/bounties'; + +/** Statuses where a bounty still accepts new entries. */ +const ACCEPTING_STATUSES = new Set([ + 'open', + 'open_for_applications', + 'upcoming', +]); + +type EntryKind = 'claim' | 'join' | 'apply' | 'view'; + +function entryKind(b: BountyPublic): { kind: EntryKind; label: string } { + const open = b.entryType === 'OPEN'; + const application = + b.entryType === 'APPLICATION_LIGHT' || b.entryType === 'APPLICATION_FULL'; + if (open && b.claimType === 'SINGLE_CLAIM') + return { kind: 'claim', label: 'Claim this bounty' }; + if (open && b.claimType === 'COMPETITION') + return { kind: 'join', label: 'Join competition' }; + if (application) return { kind: 'apply', label: 'Apply' }; + return { kind: 'view', label: 'View' }; +} + +const STATUS_LABEL: Record = { + SUBMITTED: 'Application submitted', + SHORTLISTED: 'Shortlisted', + SELECTED: 'Selected', + DECLINED: 'Not selected', + WITHDRAWN: 'Withdrawn', +}; + +const STATUS_CLASS: Record = { + SUBMITTED: 'border-blue-500/30 bg-blue-500/10 text-blue-400', + SHORTLISTED: 'border-amber-500/30 bg-amber-500/10 text-amber-400', + SELECTED: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400', + DECLINED: 'border-red-500/30 bg-red-500/10 text-red-400', + WITHDRAWN: 'border-zinc-700 bg-zinc-800/60 text-zinc-300', +}; + +export function BountyEntryCta({ + bounty, + myApplication, +}: { + bounty: BountyPublic; + myApplication: MyBountyApplication | null; +}) { + const { label } = entryKind(bounty); + + const deadlinePassed = + !!bounty.applicationWindowCloseAt && + new Date(bounty.applicationWindowCloseAt).getTime() < Date.now(); + const accepting = ACCEPTING_STATUSES.has(bounty.status); + + // An active (non-withdrawn) application means the caller is already in. + const activeStatus = + myApplication && myApplication.applicationStatus !== 'WITHDRAWN' + ? myApplication.applicationStatus + : null; + + // The real entry flow (forms / on-chain op) ships in #624; the CTA here is + // mode-aware and correctly gated, and hands off to that flow. + const onEntry = () => + toast.info('The entry flow is coming in the next step (apply/join/claim).'); + + return ( +
+ {activeStatus ? ( +
+
+ + + You're in this bounty + +
+ + {STATUS_LABEL[activeStatus] ?? activeStatus} + +
+ ) : ( + <> + + {label} + +

+ {!accepting ? ( + <> + + This bounty is not accepting entries. + + ) : deadlinePassed ? ( + <> + + Applications have closed. + + ) : myApplication?.applicationStatus === 'WITHDRAWN' ? ( + 'You withdrew earlier — you can enter again.' + ) : ( + 'Funds are held in escrow and paid on-chain to winners.' + )} +

+ + )} +
+ ); +}