Skip to content

Commit 9859329

Browse files
committed
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
1 parent 8bbb6a4 commit 9859329

6 files changed

Lines changed: 414 additions & 9 deletions

File tree

app/(landing)/bounties/page.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
1-
import { redirect } from 'next/navigation';
21
import { Metadata } from 'next';
2+
33
import { generatePageMetadata } from '@/lib/metadata';
4+
import BountiesPage from '@/components/bounties/marketplace/BountiesPage';
45

5-
export const metadata: Metadata = generatePageMetadata('grants');
6+
export const metadata: Metadata = generatePageMetadata('bounties');
67

7-
const GrantPage = () => {
8-
redirect('/coming-soon');
8+
export default function BountiesPageRoute() {
99
return (
10-
<div className='mx-auto mt-10 max-w-[1440px] px-5 py-5 text-center text-4xl font-bold text-white md:px-[50px] lg:px-[100px]'>
11-
Bounties Page
10+
<div className='relative mx-auto min-h-screen max-w-[1440px] px-5 py-8 md:px-[50px] lg:px-[100px]'>
11+
<BountiesPage />
1212
</div>
1313
);
14-
};
15-
16-
export default GrantPage;
14+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
'use client';
2+
3+
import { Search } from 'lucide-react';
4+
5+
import { Input } from '@/components/ui/input';
6+
import {
7+
Select,
8+
SelectContent,
9+
SelectItem,
10+
SelectTrigger,
11+
SelectValue,
12+
} from '@/components/ui/select';
13+
14+
export const STATUS_OPTIONS = [
15+
{ value: 'all', label: 'All statuses' },
16+
{ value: 'open', label: 'Open' },
17+
{ value: 'in_progress', label: 'In progress' },
18+
{ value: 'completed', label: 'Completed' },
19+
{ value: 'cancelled', label: 'Cancelled' },
20+
] as const;
21+
22+
export const MODE_OPTIONS = [
23+
{ value: 'all', label: 'All modes' },
24+
{ value: 'single_claim', label: 'Single claim' },
25+
{ value: 'competition', label: 'Competition' },
26+
{ value: 'application', label: 'Application' },
27+
] as const;
28+
29+
export type ModeFilter = (typeof MODE_OPTIONS)[number]['value'];
30+
31+
interface BountiesFiltersHeaderProps {
32+
search: string;
33+
status: string;
34+
mode: ModeFilter;
35+
totalCount: number;
36+
onSearch: (value: string) => void;
37+
onStatus: (value: string) => void;
38+
onMode: (value: ModeFilter) => void;
39+
}
40+
41+
export default function BountiesFiltersHeader({
42+
search,
43+
status,
44+
mode,
45+
totalCount,
46+
onSearch,
47+
onStatus,
48+
onMode,
49+
}: BountiesFiltersHeaderProps) {
50+
return (
51+
<div className='mb-8'>
52+
<div className='mb-2 flex items-end justify-between gap-4'>
53+
<div>
54+
<h1 className='text-3xl font-bold tracking-tight text-white'>
55+
Bounties
56+
</h1>
57+
<p className='mt-1 text-sm text-zinc-400'>
58+
Discover open bounties and start earning.
59+
</p>
60+
</div>
61+
<span className='shrink-0 text-sm text-zinc-500'>
62+
{totalCount} bount{totalCount === 1 ? 'y' : 'ies'}
63+
</span>
64+
</div>
65+
66+
<div className='mt-4 flex flex-wrap items-center gap-3'>
67+
<div className='relative min-w-52 flex-1'>
68+
<Search className='absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-zinc-500' />
69+
<Input
70+
type='search'
71+
placeholder='Search bounties...'
72+
value={search}
73+
onChange={e => onSearch(e.target.value)}
74+
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'
75+
/>
76+
</div>
77+
78+
<Select value={status} onValueChange={onStatus}>
79+
<SelectTrigger className='h-10 w-40 rounded-xl border-zinc-800/50 bg-zinc-900/30 text-sm text-white hover:border-zinc-700'>
80+
<SelectValue />
81+
</SelectTrigger>
82+
<SelectContent className='border-zinc-800/50 bg-zinc-950'>
83+
{STATUS_OPTIONS.map(o => (
84+
<SelectItem key={o.value} value={o.value}>
85+
{o.label}
86+
</SelectItem>
87+
))}
88+
</SelectContent>
89+
</Select>
90+
91+
<Select value={mode} onValueChange={v => onMode(v as ModeFilter)}>
92+
<SelectTrigger className='h-10 w-40 rounded-xl border-zinc-800/50 bg-zinc-900/30 text-sm text-white hover:border-zinc-700'>
93+
<SelectValue />
94+
</SelectTrigger>
95+
<SelectContent className='border-zinc-800/50 bg-zinc-950'>
96+
{MODE_OPTIONS.map(o => (
97+
<SelectItem key={o.value} value={o.value}>
98+
{o.label}
99+
</SelectItem>
100+
))}
101+
</SelectContent>
102+
</Select>
103+
</div>
104+
</div>
105+
);
106+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
'use client';
2+
3+
import React, { useEffect, useMemo, useState } from 'react';
4+
5+
import EmptyState from '@/components/EmptyState';
6+
import LoadingSpinner from '@/components/LoadingSpinner';
7+
import { BoundlessButton } from '@/components/buttons';
8+
import { useInfiniteScroll } from '@/hooks/use-infinite-scroll';
9+
import type { BountyPublic } from '@/features/bounties';
10+
import { BountyCard } from './BountyCard';
11+
import BountiesFiltersHeader, {
12+
type ModeFilter,
13+
} from './BountiesFiltersHeader';
14+
import { useInfiniteBounties } from './use-bounties-marketplace';
15+
16+
/** Client-side mode narrowing (the public list endpoint doesn't filter on mode). */
17+
function matchesMode(b: BountyPublic, mode: ModeFilter): boolean {
18+
switch (mode) {
19+
case 'single_claim':
20+
return b.claimType === 'SINGLE_CLAIM';
21+
case 'competition':
22+
return b.claimType === 'COMPETITION';
23+
case 'application':
24+
return (
25+
b.entryType === 'APPLICATION_LIGHT' ||
26+
b.entryType === 'APPLICATION_FULL'
27+
);
28+
default:
29+
return true;
30+
}
31+
}
32+
33+
export default function BountiesPage() {
34+
const [search, setSearch] = useState('');
35+
const [debouncedSearch, setDebouncedSearch] = useState('');
36+
const [status, setStatus] = useState('all');
37+
const [mode, setMode] = useState<ModeFilter>('all');
38+
39+
// Debounce the search so we don't refetch on every keystroke.
40+
useEffect(() => {
41+
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300);
42+
return () => clearTimeout(t);
43+
}, [search]);
44+
45+
const {
46+
bounties,
47+
totalCount,
48+
loading,
49+
loadingMore,
50+
error,
51+
hasMore,
52+
loadMore,
53+
} = useInfiniteBounties({
54+
status: status === 'all' ? undefined : status,
55+
search: debouncedSearch || undefined,
56+
});
57+
58+
const visible = useMemo(
59+
() => bounties.filter(b => matchesMode(b, mode)),
60+
[bounties, mode]
61+
);
62+
63+
const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null);
64+
useInfiniteScroll(sentinel, {
65+
onLoadMore: loadMore,
66+
hasMore: !!hasMore,
67+
loading: loadingMore,
68+
rootMargin: '0px 0px 1200px 0px',
69+
});
70+
71+
const hasFilters = !!debouncedSearch || status !== 'all' || mode !== 'all';
72+
const clearFilters = () => {
73+
setSearch('');
74+
setStatus('all');
75+
setMode('all');
76+
};
77+
78+
return (
79+
<div id='explore-bounties'>
80+
<BountiesFiltersHeader
81+
search={search}
82+
status={status}
83+
mode={mode}
84+
totalCount={totalCount}
85+
onSearch={setSearch}
86+
onStatus={setStatus}
87+
onMode={setMode}
88+
/>
89+
90+
{loading && (
91+
<div className='flex items-center justify-center py-24'>
92+
<LoadingSpinner size='md' variant='spinner' color='white' />
93+
</div>
94+
)}
95+
96+
{!loading && error && (
97+
<div className='py-16'>
98+
<EmptyState
99+
title="Couldn't load bounties"
100+
description={error}
101+
type='compact'
102+
/>
103+
</div>
104+
)}
105+
106+
{!loading && !error && visible.length === 0 && (
107+
<div className='py-16 text-center'>
108+
<EmptyState
109+
title='No bounties found'
110+
description={
111+
hasFilters
112+
? 'Try adjusting your filters to see more bounties.'
113+
: 'No bounties are available right now.'
114+
}
115+
type='compact'
116+
/>
117+
{hasFilters && (
118+
<div className='mt-3'>
119+
<BoundlessButton
120+
variant='outline'
121+
onClick={clearFilters}
122+
className='border-primary text-primary hover:bg-primary/10'
123+
>
124+
Clear filters
125+
</BoundlessButton>
126+
</div>
127+
)}
128+
</div>
129+
)}
130+
131+
{!loading && !error && visible.length > 0 && (
132+
<>
133+
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3'>
134+
{visible.map(b => (
135+
<BountyCard key={b.id} bounty={b} />
136+
))}
137+
</div>
138+
139+
<div ref={setSentinel} className='h-px w-full' aria-hidden />
140+
141+
{loadingMore && (
142+
<div className='mt-6 flex items-center justify-center gap-2 py-4 text-zinc-400'>
143+
<LoadingSpinner size='sm' variant='spinner' color='white' />
144+
<span className='text-sm'>Loading more...</span>
145+
</div>
146+
)}
147+
</>
148+
)}
149+
</div>
150+
);
151+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
'use client';
2+
3+
import Link from 'next/link';
4+
import { Calendar, Target } from 'lucide-react';
5+
6+
import { Badge } from '@/components/ui/badge';
7+
import { computeBountyModeLabel } from '@/components/organization/bounties/new/tabs/schemas/modeSchema';
8+
import type { BountyPublic } from '@/features/bounties';
9+
10+
/** Plain-language mode label (single claim / competition / application). */
11+
function modeLabel(b: BountyPublic): string {
12+
if (b.entryType && b.claimType) {
13+
return computeBountyModeLabel(b.entryType, b.claimType);
14+
}
15+
return 'Bounty';
16+
}
17+
18+
const STATUS_CLASS: Record<string, string> = {
19+
open: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400',
20+
in_progress: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400',
21+
completed: 'border-primary/30 bg-primary/10 text-primary',
22+
cancelled: 'border-zinc-700 bg-zinc-800/60 text-zinc-300',
23+
};
24+
25+
function formatDate(iso: string): string {
26+
return new Date(iso).toLocaleDateString(undefined, {
27+
month: 'short',
28+
day: 'numeric',
29+
year: 'numeric',
30+
});
31+
}
32+
33+
export function BountyCard({ bounty }: { bounty: BountyPublic }) {
34+
const reward = bounty.rewardAmount > 0;
35+
const statusClass =
36+
STATUS_CLASS[bounty.status] ??
37+
'border-zinc-700 bg-zinc-800/60 text-zinc-300';
38+
39+
return (
40+
<Link
41+
href={`/bounties/${bounty.id}`}
42+
className='group hover:border-primary/40 flex w-full flex-col rounded-2xl border border-zinc-800 bg-zinc-900/40 p-5 transition-colors hover:bg-zinc-900/70'
43+
aria-label={`View bounty ${bounty.title}`}
44+
>
45+
<div className='mb-3 flex items-center justify-between gap-2'>
46+
<Badge
47+
variant='outline'
48+
className='rounded-full border-zinc-700 bg-zinc-800/60 px-3 py-1 text-xs font-medium text-zinc-200'
49+
>
50+
{modeLabel(bounty)}
51+
</Badge>
52+
<Badge
53+
variant='outline'
54+
className={`rounded-full px-3 py-1 text-xs font-medium capitalize ${statusClass}`}
55+
>
56+
{bounty.status.replace(/_/g, ' ')}
57+
</Badge>
58+
</div>
59+
60+
<h3 className='group-hover:text-primary line-clamp-2 min-h-12 text-lg font-bold text-white transition-colors'>
61+
{bounty.title}
62+
</h3>
63+
{bounty.description && (
64+
<p className='mt-1 line-clamp-2 text-sm text-zinc-400'>
65+
{bounty.description}
66+
</p>
67+
)}
68+
69+
{bounty.applicationWindowCloseAt && (
70+
<p className='mt-3 flex items-center gap-1.5 text-xs text-zinc-500'>
71+
<Calendar className='h-3.5 w-3.5' />
72+
Applications close {formatDate(bounty.applicationWindowCloseAt)}
73+
</p>
74+
)}
75+
76+
<div className='mt-4 flex items-center justify-between gap-2 border-t border-zinc-800 pt-4'>
77+
<div className='flex items-center gap-2'>
78+
<div className='bg-primary/10 flex h-9 w-9 items-center justify-center rounded-lg'>
79+
<Target className='text-primary h-4 w-4' />
80+
</div>
81+
<div>
82+
<p className='text-primary text-base leading-tight font-bold'>
83+
{reward
84+
? `${bounty.rewardAmount.toLocaleString()} ${bounty.rewardCurrency}`
85+
: 'No reward set'}
86+
</p>
87+
<p className='text-[11px] text-zinc-500'>reward</p>
88+
</div>
89+
</div>
90+
<span className='max-w-[45%] truncate text-right text-xs text-zinc-400'>
91+
{bounty.organization.name}
92+
</span>
93+
</div>
94+
</Link>
95+
);
96+
}

0 commit comments

Comments
 (0)