Skip to content

Commit 928aa73

Browse files
authored
feat(bounty): bounty detail page (/bounties/[id]) (#646)
* 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 * feat(bounty): public bounty marketplace (discover/list) 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 * feat(bounty): bounty detail page (/bounties/[id]) 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
1 parent c621616 commit 928aa73

3 files changed

Lines changed: 376 additions & 0 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Metadata } from 'next';
2+
3+
import { generatePageMetadata } from '@/lib/metadata';
4+
import BountyDetail from '@/components/bounties/detail/BountyDetail';
5+
6+
export const metadata: Metadata = generatePageMetadata('bounties');
7+
8+
interface BountyDetailPageProps {
9+
params: Promise<{ id: string }>;
10+
}
11+
12+
export default async function BountyDetailPageRoute({
13+
params,
14+
}: BountyDetailPageProps) {
15+
const { id } = await params;
16+
17+
return (
18+
<div className='relative mx-auto min-h-screen max-w-[1440px] px-5 py-8 md:px-[50px] lg:px-[100px]'>
19+
<BountyDetail id={id} />
20+
</div>
21+
);
22+
}
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
'use client';
2+
3+
import React from 'react';
4+
import dynamic from 'next/dynamic';
5+
import Link from 'next/link';
6+
import {
7+
ArrowLeft,
8+
Award,
9+
Calendar,
10+
Loader2,
11+
Target,
12+
Users,
13+
} from 'lucide-react';
14+
15+
import { Badge } from '@/components/ui/badge';
16+
import EmptyState from '@/components/EmptyState';
17+
import { computeBountyModeLabel } from '@/components/organization/bounties/new/tabs/schemas/modeSchema';
18+
import { useBounty, useMyBountyApplication } from '@/features/bounties';
19+
import { BountyEntryCta } from './BountyEntryCta';
20+
21+
// Markdown renderer (matches the wizard's editor package).
22+
const Markdown = dynamic(
23+
() => import('@uiw/react-md-editor').then(mod => mod.default.Markdown),
24+
{ ssr: false }
25+
);
26+
27+
function formatDate(iso: string): string {
28+
return new Date(iso).toLocaleDateString(undefined, {
29+
month: 'short',
30+
day: 'numeric',
31+
year: 'numeric',
32+
});
33+
}
34+
35+
const ordinal = (n: number): string => {
36+
const s = ['th', 'st', 'nd', 'rd'];
37+
const v = n % 100;
38+
return `${n}${s[(v - 20) % 10] ?? s[v] ?? s[0]}`;
39+
};
40+
41+
export default function BountyDetail({ id }: { id: string }) {
42+
const { data: bounty, isLoading, error } = useBounty(id);
43+
const { data: myApplication } = useMyBountyApplication(id);
44+
45+
if (isLoading) {
46+
return (
47+
<div className='flex min-h-[50vh] items-center justify-center'>
48+
<Loader2 className='h-6 w-6 animate-spin text-zinc-500' />
49+
</div>
50+
);
51+
}
52+
53+
if (error || !bounty) {
54+
return (
55+
<div className='py-20'>
56+
<EmptyState
57+
title='Bounty not found'
58+
description='This bounty may have been removed or is not public.'
59+
type='compact'
60+
/>
61+
<div className='mt-4 text-center'>
62+
<Link
63+
href='/bounties'
64+
className='text-primary text-sm hover:underline'
65+
>
66+
Back to bounties
67+
</Link>
68+
</div>
69+
</div>
70+
);
71+
}
72+
73+
const modeLabel =
74+
bounty.entryType && bounty.claimType
75+
? computeBountyModeLabel(bounty.entryType, bounty.claimType)
76+
: 'Bounty';
77+
const currency = bounty.rewardCurrency;
78+
79+
return (
80+
<div>
81+
<Link
82+
href='/bounties'
83+
className='mb-6 inline-flex items-center gap-1.5 text-sm text-zinc-400 transition-colors hover:text-white'
84+
>
85+
<ArrowLeft className='h-4 w-4' />
86+
Back to bounties
87+
</Link>
88+
89+
<div className='grid gap-8 lg:grid-cols-[1fr_360px]'>
90+
{/* Main */}
91+
<div className='min-w-0'>
92+
<div className='mb-4 flex flex-wrap items-center gap-2'>
93+
<Badge
94+
variant='outline'
95+
className='rounded-full border-zinc-700 bg-zinc-800/60 px-3 py-1 text-xs font-medium text-zinc-200'
96+
>
97+
{modeLabel}
98+
</Badge>
99+
<Badge
100+
variant='outline'
101+
className='rounded-full border-zinc-700 bg-zinc-800/60 px-3 py-1 text-xs font-medium text-zinc-300 capitalize'
102+
>
103+
{bounty.status.replace(/_/g, ' ')}
104+
</Badge>
105+
</div>
106+
107+
<h1 className='text-2xl font-bold tracking-tight text-white sm:text-3xl'>
108+
{bounty.title}
109+
</h1>
110+
<p className='mt-2 text-sm text-zinc-400'>
111+
by <span className='text-zinc-200'>{bounty.organization.name}</span>
112+
</p>
113+
114+
{/* Description (markdown) */}
115+
<div className='boundless-markdown mt-8' data-color-mode='dark'>
116+
<Markdown
117+
source={bounty.description}
118+
style={{ background: 'transparent', color: '#e4e4e7' }}
119+
/>
120+
</div>
121+
122+
{/* Prize tiers */}
123+
{bounty.prizeTiers.length > 0 && (
124+
<div className='mt-10'>
125+
<h2 className='mb-3 flex items-center gap-2 text-lg font-semibold text-white'>
126+
<Award className='text-primary h-5 w-5' />
127+
Prize tiers
128+
</h2>
129+
<div className='space-y-2'>
130+
{bounty.prizeTiers
131+
.slice()
132+
.sort((a, b) => a.position - b.position)
133+
.map(tier => (
134+
<div
135+
key={tier.position}
136+
className='flex items-center justify-between rounded-lg border border-zinc-800 bg-zinc-900/30 px-4 py-3'
137+
>
138+
<span className='text-sm font-medium text-zinc-300'>
139+
{ordinal(tier.position)} place
140+
</span>
141+
<span className='text-primary text-sm font-semibold'>
142+
{Number(tier.amount).toLocaleString()} {currency}
143+
</span>
144+
</div>
145+
))}
146+
</div>
147+
</div>
148+
)}
149+
</div>
150+
151+
{/* Sidebar */}
152+
<aside className='space-y-4 lg:sticky lg:top-6 lg:self-start'>
153+
{/* Reward */}
154+
<div className='rounded-2xl border border-zinc-800 bg-zinc-900/40 p-5'>
155+
<div className='flex items-center gap-3'>
156+
<div className='bg-primary/10 flex h-11 w-11 items-center justify-center rounded-xl'>
157+
<Target className='text-primary h-5 w-5' />
158+
</div>
159+
<div>
160+
<p className='text-primary text-xl font-bold'>
161+
{bounty.rewardAmount > 0
162+
? `${bounty.rewardAmount.toLocaleString()} ${currency}`
163+
: 'No reward set'}
164+
</p>
165+
<p className='text-xs text-zinc-500'>total reward pool</p>
166+
</div>
167+
</div>
168+
169+
{/* Eligibility / mode facts */}
170+
<dl className='mt-4 space-y-2 border-t border-zinc-800 pt-4 text-sm'>
171+
{bounty.entryType === 'OPEN' &&
172+
bounty.claimType === 'SINGLE_CLAIM' &&
173+
bounty.reputationMinimum != null && (
174+
<Row
175+
label='Minimum reputation'
176+
value={String(bounty.reputationMinimum)}
177+
/>
178+
)}
179+
{bounty.applicationWindowCloseAt && (
180+
<Row
181+
icon={<Calendar className='h-3.5 w-3.5' />}
182+
label='Applications close'
183+
value={formatDate(bounty.applicationWindowCloseAt)}
184+
/>
185+
)}
186+
{bounty.maxApplicants != null && (
187+
<Row
188+
icon={<Users className='h-3.5 w-3.5' />}
189+
label='Max applicants'
190+
value={String(bounty.maxApplicants)}
191+
/>
192+
)}
193+
{bounty.shortlistSize != null && (
194+
<Row
195+
label='Shortlist size'
196+
value={String(bounty.shortlistSize)}
197+
/>
198+
)}
199+
</dl>
200+
</div>
201+
202+
{/* Mode-aware CTA + caller status */}
203+
<BountyEntryCta
204+
bounty={bounty}
205+
myApplication={myApplication ?? null}
206+
/>
207+
</aside>
208+
</div>
209+
</div>
210+
);
211+
}
212+
213+
function Row({
214+
label,
215+
value,
216+
icon,
217+
}: {
218+
label: string;
219+
value: string;
220+
icon?: React.ReactNode;
221+
}) {
222+
return (
223+
<div className='flex items-center justify-between gap-3'>
224+
<dt className='flex items-center gap-1.5 text-zinc-400'>
225+
{icon}
226+
{label}
227+
</dt>
228+
<dd className='text-right font-medium text-white'>{value}</dd>
229+
</div>
230+
);
231+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
'use client';
2+
3+
import { toast } from 'sonner';
4+
import { CheckCircle2, Lock } from 'lucide-react';
5+
6+
import { BoundlessButton } from '@/components/buttons';
7+
import { Badge } from '@/components/ui/badge';
8+
import type { BountyPublic, MyBountyApplication } from '@/features/bounties';
9+
10+
/** Statuses where a bounty still accepts new entries. */
11+
const ACCEPTING_STATUSES = new Set([
12+
'open',
13+
'open_for_applications',
14+
'upcoming',
15+
]);
16+
17+
type EntryKind = 'claim' | 'join' | 'apply' | 'view';
18+
19+
function entryKind(b: BountyPublic): { kind: EntryKind; label: string } {
20+
const open = b.entryType === 'OPEN';
21+
const application =
22+
b.entryType === 'APPLICATION_LIGHT' || b.entryType === 'APPLICATION_FULL';
23+
if (open && b.claimType === 'SINGLE_CLAIM')
24+
return { kind: 'claim', label: 'Claim this bounty' };
25+
if (open && b.claimType === 'COMPETITION')
26+
return { kind: 'join', label: 'Join competition' };
27+
if (application) return { kind: 'apply', label: 'Apply' };
28+
return { kind: 'view', label: 'View' };
29+
}
30+
31+
const STATUS_LABEL: Record<string, string> = {
32+
SUBMITTED: 'Application submitted',
33+
SHORTLISTED: 'Shortlisted',
34+
SELECTED: 'Selected',
35+
DECLINED: 'Not selected',
36+
WITHDRAWN: 'Withdrawn',
37+
};
38+
39+
const STATUS_CLASS: Record<string, string> = {
40+
SUBMITTED: 'border-blue-500/30 bg-blue-500/10 text-blue-400',
41+
SHORTLISTED: 'border-amber-500/30 bg-amber-500/10 text-amber-400',
42+
SELECTED: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400',
43+
DECLINED: 'border-red-500/30 bg-red-500/10 text-red-400',
44+
WITHDRAWN: 'border-zinc-700 bg-zinc-800/60 text-zinc-300',
45+
};
46+
47+
export function BountyEntryCta({
48+
bounty,
49+
myApplication,
50+
}: {
51+
bounty: BountyPublic;
52+
myApplication: MyBountyApplication | null;
53+
}) {
54+
const { label } = entryKind(bounty);
55+
56+
const deadlinePassed =
57+
!!bounty.applicationWindowCloseAt &&
58+
new Date(bounty.applicationWindowCloseAt).getTime() < Date.now();
59+
const accepting = ACCEPTING_STATUSES.has(bounty.status);
60+
61+
// An active (non-withdrawn) application means the caller is already in.
62+
const activeStatus =
63+
myApplication && myApplication.applicationStatus !== 'WITHDRAWN'
64+
? myApplication.applicationStatus
65+
: null;
66+
67+
// The real entry flow (forms / on-chain op) ships in #624; the CTA here is
68+
// mode-aware and correctly gated, and hands off to that flow.
69+
const onEntry = () =>
70+
toast.info('The entry flow is coming in the next step (apply/join/claim).');
71+
72+
return (
73+
<div className='rounded-2xl border border-zinc-800 bg-zinc-900/40 p-5'>
74+
{activeStatus ? (
75+
<div className='space-y-3'>
76+
<div className='flex items-center gap-2'>
77+
<CheckCircle2 className='text-primary h-4 w-4' />
78+
<span className='text-sm font-medium text-white'>
79+
You&apos;re in this bounty
80+
</span>
81+
</div>
82+
<Badge
83+
variant='outline'
84+
className={`rounded-full px-3 py-1 text-xs font-medium ${
85+
STATUS_CLASS[activeStatus] ??
86+
'border-zinc-700 bg-zinc-800/60 text-zinc-300'
87+
}`}
88+
>
89+
{STATUS_LABEL[activeStatus] ?? activeStatus}
90+
</Badge>
91+
</div>
92+
) : (
93+
<>
94+
<BoundlessButton
95+
size='lg'
96+
className='w-full'
97+
disabled={!accepting || deadlinePassed}
98+
onClick={onEntry}
99+
>
100+
{label}
101+
</BoundlessButton>
102+
<p className='mt-2 flex items-center justify-center gap-1.5 text-center text-xs text-zinc-500'>
103+
{!accepting ? (
104+
<>
105+
<Lock className='h-3.5 w-3.5' />
106+
This bounty is not accepting entries.
107+
</>
108+
) : deadlinePassed ? (
109+
<>
110+
<Lock className='h-3.5 w-3.5' />
111+
Applications have closed.
112+
</>
113+
) : myApplication?.applicationStatus === 'WITHDRAWN' ? (
114+
'You withdrew earlier — you can enter again.'
115+
) : (
116+
'Funds are held in escrow and paid on-chain to winners.'
117+
)}
118+
</p>
119+
</>
120+
)}
121+
</div>
122+
);
123+
}

0 commit comments

Comments
 (0)