Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions app/(landing)/bounties/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<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]'>
Bounties Page
<div className='relative mx-auto min-h-screen max-w-[1440px] px-5 py-8 md:px-[50px] lg:px-[100px]'>
<BountiesPage />
</div>
);
};

export default GrantPage;
}
106 changes: 106 additions & 0 deletions components/bounties/marketplace/BountiesFiltersHeader.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className='mb-8'>
<div className='mb-2 flex items-end justify-between gap-4'>
<div>
<h1 className='text-3xl font-bold tracking-tight text-white'>
Bounties
</h1>
<p className='mt-1 text-sm text-zinc-400'>
Discover open bounties and start earning.
</p>
</div>
<span className='shrink-0 text-sm text-zinc-500'>
{totalCount} bount{totalCount === 1 ? 'y' : 'ies'}
</span>
</div>

<div className='mt-4 flex flex-wrap items-center gap-3'>
<div className='relative min-w-52 flex-1'>
<Search className='absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-zinc-500' />
<Input
type='search'
placeholder='Search bounties...'
value={search}
onChange={e => 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'
/>
</div>

<Select value={status} onValueChange={onStatus}>
<SelectTrigger className='h-10 w-40 rounded-xl border-zinc-800/50 bg-zinc-900/30 text-sm text-white hover:border-zinc-700'>
<SelectValue />
</SelectTrigger>
<SelectContent className='border-zinc-800/50 bg-zinc-950'>
{STATUS_OPTIONS.map(o => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>

<Select value={mode} onValueChange={v => onMode(v as ModeFilter)}>
<SelectTrigger className='h-10 w-40 rounded-xl border-zinc-800/50 bg-zinc-900/30 text-sm text-white hover:border-zinc-700'>
<SelectValue />
</SelectTrigger>
<SelectContent className='border-zinc-800/50 bg-zinc-950'>
{MODE_OPTIONS.map(o => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}
151 changes: 151 additions & 0 deletions components/bounties/marketplace/BountiesPage.tsx
Original file line number Diff line number Diff line change
@@ -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<ModeFilter>('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<HTMLDivElement | null>(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 (
<div id='explore-bounties'>
<BountiesFiltersHeader
search={search}
status={status}
mode={mode}
totalCount={totalCount}
onSearch={setSearch}
onStatus={setStatus}
onMode={setMode}
/>

{loading && (
<div className='flex items-center justify-center py-24'>
<LoadingSpinner size='md' variant='spinner' color='white' />
</div>
)}

{!loading && error && (
<div className='py-16'>
<EmptyState
title="Couldn't load bounties"
description={error}
type='compact'
/>
</div>
)}

{!loading && !error && visible.length === 0 && (
<div className='py-16 text-center'>
<EmptyState
title='No bounties found'
description={
hasFilters
? 'Try adjusting your filters to see more bounties.'
: 'No bounties are available right now.'
}
type='compact'
/>
{hasFilters && (
<div className='mt-3'>
<BoundlessButton
variant='outline'
onClick={clearFilters}
className='border-primary text-primary hover:bg-primary/10'
>
Clear filters
</BoundlessButton>
</div>
)}
</div>
)}

{!loading && !error && visible.length > 0 && (
<>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3'>
{visible.map(b => (
<BountyCard key={b.id} bounty={b} />
))}
</div>

<div ref={setSentinel} className='h-px w-full' aria-hidden />

{loadingMore && (
<div className='mt-6 flex items-center justify-center gap-2 py-4 text-zinc-400'>
<LoadingSpinner size='sm' variant='spinner' color='white' />
<span className='text-sm'>Loading more...</span>
</div>
)}
</>
)}
</div>
);
}
96 changes: 96 additions & 0 deletions components/bounties/marketplace/BountyCard.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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 (
<Link
href={`/bounties/${bounty.id}`}
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'
aria-label={`View bounty ${bounty.title}`}
>
<div className='mb-3 flex items-center justify-between gap-2'>
<Badge
variant='outline'
className='rounded-full border-zinc-700 bg-zinc-800/60 px-3 py-1 text-xs font-medium text-zinc-200'
>
{modeLabel(bounty)}
</Badge>
<Badge
variant='outline'
className={`rounded-full px-3 py-1 text-xs font-medium capitalize ${statusClass}`}
>
{bounty.status.replace(/_/g, ' ')}
</Badge>
</div>

<h3 className='group-hover:text-primary line-clamp-2 min-h-12 text-lg font-bold text-white transition-colors'>
{bounty.title}
</h3>
{bounty.description && (
<p className='mt-1 line-clamp-2 text-sm text-zinc-400'>
{bounty.description}
</p>
)}

{bounty.applicationWindowCloseAt && (
<p className='mt-3 flex items-center gap-1.5 text-xs text-zinc-500'>
<Calendar className='h-3.5 w-3.5' />
Applications close {formatDate(bounty.applicationWindowCloseAt)}
</p>
)}

<div className='mt-4 flex items-center justify-between gap-2 border-t border-zinc-800 pt-4'>
<div className='flex items-center gap-2'>
<div className='bg-primary/10 flex h-9 w-9 items-center justify-center rounded-lg'>
<Target className='text-primary h-4 w-4' />
</div>
<div>
<p className='text-primary text-base leading-tight font-bold'>
{reward
? `${bounty.rewardAmount.toLocaleString()} ${bounty.rewardCurrency}`
: 'No reward set'}
</p>
<p className='text-[11px] text-zinc-500'>reward</p>
</div>
</div>
<span className='max-w-[45%] truncate text-right text-xs text-zinc-400'>
{bounty.organization.name}
</span>
</div>
</Link>
);
}
Loading