From 5c6cab2538be8495a51ad950c2dd8a1136259ae0 Mon Sep 17 00:00:00 2001 From: TTClaw Agent Date: Fri, 17 Apr 2026 19:59:54 +0800 Subject: [PATCH] feat(frontend): add search bar to bounties page (#823) - Add debounced search input filtering by title, description, and tags - Clear button to reset search - Works alongside existing skill and status filters - Client-side filtering on loaded bounties --- frontend/src/api/bounties.ts | 1 + frontend/src/components/bounty/BountyGrid.tsx | 53 ++++++++++++++++--- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/frontend/src/api/bounties.ts b/frontend/src/api/bounties.ts index 921a65ebd..588efdac3 100644 --- a/frontend/src/api/bounties.ts +++ b/frontend/src/api/bounties.ts @@ -15,6 +15,7 @@ export interface BountiesListParams { skill?: string; tier?: string; reward_token?: string; + search?: string; } export interface BountiesListResponse { diff --git a/frontend/src/components/bounty/BountyGrid.tsx b/frontend/src/components/bounty/BountyGrid.tsx index 7709ab94c..d092716b4 100644 --- a/frontend/src/components/bounty/BountyGrid.tsx +++ b/frontend/src/components/bounty/BountyGrid.tsx @@ -1,7 +1,7 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { motion } from 'framer-motion'; -import { ChevronDown, Loader2, Plus } from 'lucide-react'; +import { ChevronDown, Loader2, Plus, Search, X } from 'lucide-react'; import { BountyCard } from './BountyCard'; import { useInfiniteBounties } from '../../hooks/useBounties'; import { staggerContainer, staggerItem } from '../../lib/animations'; @@ -11,6 +11,14 @@ const FILTER_SKILLS = ['All', 'TypeScript', 'Rust', 'Solidity', 'Python', 'Go', export function BountyGrid() { const [activeSkill, setActiveSkill] = useState('All'); const [statusFilter, setStatusFilter] = useState('open'); + const [searchQuery, setSearchQuery] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + + // Debounce search input by 300ms + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(searchQuery.trim()), 300); + return () => clearTimeout(timer); + }, [searchQuery]); const params = { status: statusFilter, @@ -22,11 +30,22 @@ export function BountyGrid() { const allBounties = data?.pages.flatMap((p) => p.items) ?? []; + // Client-side search filter across title, description, and tags + const filteredBounties = useMemo(() => { + if (!debouncedSearch) return allBounties; + const q = debouncedSearch.toLowerCase(); + return allBounties.filter((b) => + b.title?.toLowerCase().includes(q) || + b.description?.toLowerCase().includes(q) || + b.tags?.some((t: string) => t.toLowerCase().includes(q)) + ); + }, [allBounties, debouncedSearch]); + return (
{/* Header row */} -
+

Open Bounties

+ {/* Search bar */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search bounties by title, description, or tags..." + className="w-full bg-forge-800 border border-border rounded-lg pl-10 pr-10 py-2.5 text-sm text-text-primary placeholder:text-text-muted focus:border-emerald outline-none transition-colors duration-150" + /> + {searchQuery && ( + + )} +
+ {/* Filter pills */}
{FILTER_SKILLS.map((skill) => ( @@ -93,17 +132,17 @@ export function BountyGrid() { )} {/* Empty state */} - {!isLoading && !isError && allBounties.length === 0 && ( + {!isLoading && !isError && filteredBounties.length === 0 && (

No bounties found

- {activeSkill !== 'All' ? `Try a different language filter.` : 'Check back soon for new bounties.'} + {searchQuery ? `No results for "${searchQuery}". Try different keywords.` : activeSkill !== 'All' ? `Try a different language filter.` : 'Check back soon for new bounties.'}

)} {/* Bounty grid */} - {!isLoading && allBounties.length > 0 && ( + {!isLoading && filteredBounties.length > 0 && ( - {allBounties.map((bounty) => ( + {filteredBounties.map((bounty) => (