From ad9a62165c0a8f654a9623dc4b517384a9a7e533 Mon Sep 17 00:00:00 2001 From: waterWang Date: Sun, 2 Aug 2026 19:42:36 +0800 Subject: [PATCH 1/2] feat: add advanced bounty search with multi-select filters, reward range, deadline, pagination (solfoundry#842) - Add BountyBoardFilters type and DEFAULT_FILTERS - Create BountyFilters component with search, category chips, skill multi-select, tier, reward presets, and deadline filter - Create Pagination component with page navigation, ellipsis, and accessibility - Update BountyGrid to use advanced filters and pagination - Add search, skills, category, reward_min, reward_max, deadline_before params to API [fj4WqyCCw3C5ShR1RfB7MoBPTpkRrBFYP1uT35g3MvT] --- frontend/src/api/bounties.ts | 6 + .../src/components/bounties/BountyFilters.tsx | 223 ++++++++++++++++++ .../src/components/bounties/Pagination.tsx | 78 ++++++ frontend/src/components/bounties/index.ts | 2 + frontend/src/components/bounty/BountyGrid.tsx | 107 +++++---- frontend/src/types/bounty.ts | 20 ++ 6 files changed, 391 insertions(+), 45 deletions(-) create mode 100644 frontend/src/components/bounties/BountyFilters.tsx create mode 100644 frontend/src/components/bounties/Pagination.tsx create mode 100644 frontend/src/components/bounties/index.ts 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..ca75793c0 100644 --- a/frontend/src/components/bounty/BountyGrid.tsx +++ b/frontend/src/components/bounty/BountyGrid.tsx @@ -1,26 +1,54 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { Link } 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 [filters, setFilters] = useState(DEFAULT_FILTERS); + const [page, setPage] = useState(1); const [statusFilter, setStatusFilter] = useState('open'); - const params = { - status: statusFilter, - skill: activeSkill !== 'All' ? activeSkill : undefined, - }; + const apiParams = useMemo(() => { + const params: Record = { + status: statusFilter, + limit: ITEMS_PER_PAGE, + offset: (page - 1) * ITEMS_PER_PAGE, + }; - const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError } = - useInfiniteBounties(params); + 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; - const allBounties = data?.pages.flatMap((p) => p.items) ?? []; + 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 +68,10 @@ export function BountyGrid() {
-
- {/* Filter pills */} -
- {FILTER_SKILLS.map((skill) => ( - - ))} + {/* Advanced filters */} +
+
{/* Loading state */} @@ -93,17 +117,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 +143,13 @@ export function BountyGrid() { )} - {/* Load more */} - {hasNextPage && ( -
- + {/* Pagination */} + {!isLoading && totalPages > 1 && ( +
+
)}
); -} +} \ No newline at end of file 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: '', +}; From 81a0f5b4e2eb4664aef8b996faea9f17f4e096ab Mon Sep 17 00:00:00 2001 From: waterWang Date: Sun, 2 Aug 2026 19:46:09 +0800 Subject: [PATCH 2/2] feat: add URL search params persistence for bounty filters (solfoundry#842) - Persist filters, page, status to URL search params for bookmarkable URLs - Restore filter state from URL on mount - Fix test environment NODE_ENV so act() works in vitest [fj4WqyCCw3C5ShR1RfB7MoBPTpkRrBFYP1uT35g3MvT] --- frontend/src/components/bounty/BountyGrid.tsx | 41 +++++++++++++++++-- frontend/src/test-setup.ts | 2 + 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/bounty/BountyGrid.tsx b/frontend/src/components/bounty/BountyGrid.tsx index ca75793c0..89bdb01f1 100644 --- a/frontend/src/components/bounty/BountyGrid.tsx +++ b/frontend/src/components/bounty/BountyGrid.tsx @@ -1,5 +1,6 @@ -import React, { useState, useMemo, useCallback } 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 { Plus, Loader2 } from 'lucide-react'; import { BountyCard } from './BountyCard'; @@ -12,9 +13,41 @@ import { DEFAULT_FILTERS } from '../../types/bounty'; const ITEMS_PER_PAGE = 12; export function BountyGrid() { - const [filters, setFilters] = useState(DEFAULT_FILTERS); - const [page, setPage] = useState(1); - 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'); + + // 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 apiParams = useMemo(() => { const params: Record = { 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';