diff --git a/frontend/src/api/bounties.ts b/frontend/src/api/bounties.ts
index 921a65ebd..27e0054c2 100644
--- a/frontend/src/api/bounties.ts
+++ b/frontend/src/api/bounties.ts
@@ -15,6 +15,12 @@ export interface BountiesListParams {
skill?: string;
tier?: string;
reward_token?: string;
+ search?: string;
+ skills?: string[];
+ reward_min?: number;
+ reward_max?: number;
+ deadline_before?: string;
+ category?: string;
}
export interface BountiesListResponse {
diff --git a/frontend/src/components/bounties/BountyFilters.tsx b/frontend/src/components/bounties/BountyFilters.tsx
new file mode 100644
index 000000000..ca433a9e1
--- /dev/null
+++ b/frontend/src/components/bounties/BountyFilters.tsx
@@ -0,0 +1,223 @@
+import React, { useState } from 'react';
+import { Search, X, SlidersHorizontal, ChevronDown } from 'lucide-react';
+import type { BountyBoardFilters } from '../../types/bounty';
+import { DEFAULT_FILTERS } from '../../types/bounty';
+
+const CATEGORIES = ['All', 'DeFi', 'AI', 'Infrastructure', 'Security', 'Gaming', 'NFT', 'Tooling', 'Other'];
+const SKILLS = ['TypeScript', 'Rust', 'Solidity', 'Python', 'Go', 'JavaScript', 'React', 'Move'];
+const TIERS = ['T1', 'T2', 'T3'];
+const REWARD_PRESETS = [
+ { label: 'All', min: 0, max: 500000 },
+ { label: 'Under 100K', min: 0, max: 100000 },
+ { label: '100K–250K', min: 100000, max: 250000 },
+ { label: '250K–500K', min: 250000, max: 500000 },
+ { label: '500K+', min: 500000, max: 1000000 },
+];
+
+interface BountyFiltersProps {
+ filters: BountyBoardFilters;
+ onFilterChange: (key: string, value: unknown) => void;
+ onReset: () => void;
+ resultCount: number;
+ totalCount: number;
+}
+
+export function BountyFilters({ filters, onFilterChange, onReset, resultCount, totalCount }: BountyFiltersProps) {
+ const [showAdvanced, setShowAdvanced] = useState(false);
+ const hasActiveFilters = JSON.stringify(filters) !== JSON.stringify(DEFAULT_FILTERS);
+
+ const activePreset = REWARD_PRESETS.find(
+ (p) => p.min === filters.rewardMin && p.max === filters.rewardMax,
+ );
+
+ return (
+
+ {/* Search + Toggle row */}
+
+ {/* Search input */}
+
+
+ onFilterChange('searchQuery', e.target.value)}
+ placeholder="Search bounties by title, description, or tags..."
+ className="w-full pl-10 pr-4 py-2.5 bg-forge-800 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:border-emerald outline-none transition-colors duration-150"
+ data-testid="search-input"
+ />
+ {filters.searchQuery && (
+
+ )}
+
+
+ {/* Advanced toggle */}
+
+
+ {/* Reset */}
+ {hasActiveFilters && (
+
+ )}
+
+
+ {/* Category chips */}
+
+ {CATEGORIES.map((cat) => {
+ const catKey = cat.toLowerCase();
+ const isActive = filters.category === catKey;
+ return (
+
+ );
+ })}
+
+
+ {/* Skills multi-select (always visible) */}
+
+ {SKILLS.map((skill) => {
+ const isSelected = filters.skills.includes(skill);
+ return (
+
+ );
+ })}
+
+
+ {/* Advanced filters panel */}
+ {showAdvanced && (
+
+ {/* Tier filter */}
+
+
+
+
+ {TIERS.map((tier) => {
+ const isActive = filters.tier === tier;
+ const tierColors: Record = {
+ T1: isActive ? 'bg-tier-t1/20 text-tier-t1 border-tier-t1/30' : '',
+ T2: isActive ? 'bg-tier-t2/20 text-tier-t2 border-tier-t2/30' : '',
+ T3: isActive ? 'bg-tier-t3/20 text-tier-t3 border-tier-t3/30' : '',
+ };
+ return (
+
+ );
+ })}
+
+
+
+ {/* Reward range */}
+
+
+
+ {REWARD_PRESETS.map((preset) => {
+ const isActive = activePreset?.label === preset.label;
+ return (
+
+ );
+ })}
+
+
+
+ {/* Deadline filter */}
+
+
+ onFilterChange('deadlineBefore', e.target.value)}
+ data-testid="deadline-filter"
+ aria-label="Deadline before date"
+ className="bg-forge-800 border border-border rounded-lg px-3 py-1.5 text-sm text-text-primary focus:border-emerald outline-none transition-colors duration-150"
+ />
+
+
+ )}
+
+ {/* Result count */}
+
+ {resultCount} of {totalCount} bounties
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/bounties/Pagination.tsx b/frontend/src/components/bounties/Pagination.tsx
new file mode 100644
index 000000000..0e214dbf5
--- /dev/null
+++ b/frontend/src/components/bounties/Pagination.tsx
@@ -0,0 +1,78 @@
+import React from 'react';
+import { ChevronLeft, ChevronRight } from 'lucide-react';
+
+interface PaginationProps {
+ page: number;
+ totalPages: number;
+ onPageChange: (page: number) => void;
+}
+
+function getPageNumbers(current: number, total: number): (number | 'ellipsis')[] {
+ if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
+
+ const pages: (number | 'ellipsis')[] = [1];
+
+ if (current > 3) pages.push('ellipsis');
+
+ const start = Math.max(2, current - 1);
+ const end = Math.min(total - 1, current + 1);
+
+ for (let i = start; i <= end; i++) {
+ pages.push(i);
+ }
+
+ if (current < total - 2) pages.push('ellipsis');
+
+ pages.push(total);
+
+ return pages;
+}
+
+export function Pagination({ page, totalPages, onPageChange }: PaginationProps) {
+ if (totalPages <= 1) return null;
+
+ const pages = getPageNumbers(page, totalPages);
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/bounties/index.ts b/frontend/src/components/bounties/index.ts
new file mode 100644
index 000000000..946cf523f
--- /dev/null
+++ b/frontend/src/components/bounties/index.ts
@@ -0,0 +1,2 @@
+export { BountyFilters } from './BountyFilters';
+export { Pagination } from './Pagination';
\ No newline at end of file
diff --git a/frontend/src/components/bounty/BountyGrid.tsx b/frontend/src/components/bounty/BountyGrid.tsx
index 7709ab94c..89bdb01f1 100644
--- a/frontend/src/components/bounty/BountyGrid.tsx
+++ b/frontend/src/components/bounty/BountyGrid.tsx
@@ -1,26 +1,87 @@
-import React, { useState } from 'react';
+import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom';
+import { useSearchParams } from 'react-router-dom';
import { motion } from 'framer-motion';
-import { ChevronDown, Loader2, Plus } from 'lucide-react';
+import { Plus, Loader2 } from 'lucide-react';
import { BountyCard } from './BountyCard';
-import { useInfiniteBounties } from '../../hooks/useBounties';
+import { useBounties } from '../../hooks/useBounties';
+import { BountyFilters, Pagination } from '../bounties';
import { staggerContainer, staggerItem } from '../../lib/animations';
+import type { BountyBoardFilters } from '../../types/bounty';
+import { DEFAULT_FILTERS } from '../../types/bounty';
-const FILTER_SKILLS = ['All', 'TypeScript', 'Rust', 'Solidity', 'Python', 'Go', 'JavaScript'];
+const ITEMS_PER_PAGE = 12;
export function BountyGrid() {
- const [activeSkill, setActiveSkill] = useState('All');
- const [statusFilter, setStatusFilter] = useState('open');
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [filters, setFilters] = useState(() => {
+ // Restore filters from URL search params for persistence
+ const fromUrl = (key: string, fallback: string) => searchParams.get(key) ?? fallback;
+ const skillsRaw = searchParams.get('skills');
+ return {
+ category: fromUrl('category', DEFAULT_FILTERS.category),
+ skills: skillsRaw ? skillsRaw.split(',').filter(Boolean) : DEFAULT_FILTERS.skills,
+ tier: fromUrl('tier', DEFAULT_FILTERS.tier),
+ rewardMin: Number(fromUrl('rewardMin', String(DEFAULT_FILTERS.rewardMin))),
+ rewardMax: Number(fromUrl('rewardMax', String(DEFAULT_FILTERS.rewardMax))),
+ deadlineBefore: fromUrl('deadlineBefore', DEFAULT_FILTERS.deadlineBefore),
+ searchQuery: fromUrl('q', DEFAULT_FILTERS.searchQuery),
+ };
+ });
+ const [page, setPage] = useState(() => {
+ const p = searchParams.get('page');
+ return p ? Math.max(1, parseInt(p, 10)) : 1;
+ });
+ const [statusFilter, setStatusFilter] = useState(() => searchParams.get('status') ?? 'open');
- const params = {
- status: statusFilter,
- skill: activeSkill !== 'All' ? activeSkill : undefined,
- };
+ // Sync filters to URL search params for search persistence
+ useEffect(() => {
+ const params = new URLSearchParams();
+ if (filters.category !== DEFAULT_FILTERS.category) params.set('category', filters.category);
+ if (filters.skills.length > 0) params.set('skills', filters.skills.join(','));
+ if (filters.tier) params.set('tier', filters.tier);
+ if (filters.rewardMin !== DEFAULT_FILTERS.rewardMin) params.set('rewardMin', String(filters.rewardMin));
+ if (filters.rewardMax !== DEFAULT_FILTERS.rewardMax) params.set('rewardMax', String(filters.rewardMax));
+ if (filters.deadlineBefore) params.set('deadlineBefore', filters.deadlineBefore);
+ if (filters.searchQuery) params.set('q', filters.searchQuery);
+ if (page > 1) params.set('page', String(page));
+ if (statusFilter !== 'open') params.set('status', statusFilter);
+ setSearchParams(params, { replace: true });
+ }, [filters, page, statusFilter, setSearchParams]);
- const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError } =
- useInfiniteBounties(params);
+ const apiParams = useMemo(() => {
+ const params: Record = {
+ status: statusFilter,
+ limit: ITEMS_PER_PAGE,
+ offset: (page - 1) * ITEMS_PER_PAGE,
+ };
- const allBounties = data?.pages.flatMap((p) => p.items) ?? [];
+ if (filters.searchQuery) params.search = filters.searchQuery;
+ if (filters.category && filters.category !== 'all') params.category = filters.category;
+ if (filters.skills.length > 0) params.skills = filters.skills.join(',');
+ if (filters.tier) params.tier = filters.tier;
+ if (filters.rewardMin > 0) params.reward_min = filters.rewardMin;
+ if (filters.rewardMax < 500000) params.reward_max = filters.rewardMax;
+ if (filters.deadlineBefore) params.deadline_before = filters.deadlineBefore;
+
+ return params;
+ }, [filters, page, statusFilter]);
+
+ const { data, isLoading, isError } = useBounties(apiParams);
+
+ const bounties = data?.items ?? [];
+ const totalCount = data?.total ?? 0;
+ const totalPages = Math.max(1, Math.ceil(totalCount / ITEMS_PER_PAGE));
+
+ const handleFilterChange = useCallback((key: string, value: unknown) => {
+ setFilters((prev) => ({ ...prev, [key]: value }));
+ setPage(1);
+ }, []);
+
+ const handleReset = useCallback(() => {
+ setFilters(DEFAULT_FILTERS);
+ setPage(1);
+ }, []);
return (
@@ -40,7 +101,10 @@ export function BountyGrid() {
-
- {/* Filter pills */}
-
- {FILTER_SKILLS.map((skill) => (
-
- ))}
+ {/* Advanced filters */}
+
+
{/* Loading state */}
@@ -93,17 +150,17 @@ export function BountyGrid() {
)}
{/* Empty state */}
- {!isLoading && !isError && allBounties.length === 0 && (
+ {!isLoading && !isError && bounties.length === 0 && (
No bounties found
- {activeSkill !== 'All' ? `Try a different language filter.` : 'Check back soon for new bounties.'}
+ Try adjusting your filters or search query.
)}
{/* Bounty grid */}
- {!isLoading && allBounties.length > 0 && (
+ {!isLoading && bounties.length > 0 && (
- {allBounties.map((bounty) => (
+ {bounties.map((bounty) => (
@@ -119,20 +176,13 @@ export function BountyGrid() {
)}
- {/* Load more */}
- {hasNextPage && (
-
-
+ {/* Pagination */}
+ {!isLoading && totalPages > 1 && (
+
)}
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts
index 7b0828bfa..ee7c0a4a9 100644
--- a/frontend/src/test-setup.ts
+++ b/frontend/src/test-setup.ts
@@ -1 +1,3 @@
import '@testing-library/jest-dom';
+// Ensure React uses development mode so act() is available
+process.env.NODE_ENV = 'test';
diff --git a/frontend/src/types/bounty.ts b/frontend/src/types/bounty.ts
index 4930ad861..d0ec10d3e 100644
--- a/frontend/src/types/bounty.ts
+++ b/frontend/src/types/bounty.ts
@@ -72,3 +72,23 @@ export interface EscrowVerifyResult {
amount_verified?: number;
error?: string;
}
+
+export interface BountyBoardFilters {
+ category: string;
+ skills: string[];
+ tier: string;
+ rewardMin: number;
+ rewardMax: number;
+ deadlineBefore: string;
+ searchQuery: string;
+}
+
+export const DEFAULT_FILTERS: BountyBoardFilters = {
+ category: 'all',
+ skills: [],
+ tier: '',
+ rewardMin: 0,
+ rewardMax: 500000,
+ deadlineBefore: '',
+ searchQuery: '',
+};