diff --git a/frontend/src/components/bounty/BountyGrid.tsx b/frontend/src/components/bounty/BountyGrid.tsx index 7709ab94c..4edcffd5a 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, useMemo, useCallback } 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,24 @@ 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(''); + const [debounceTimer, setDebounceTimer] = useState | null>(null); + + // Debounced search handler + const handleSearchChange = useCallback((value: string) => { + setSearchQuery(value); + if (debounceTimer) clearTimeout(debounceTimer); + const timer = setTimeout(() => { + setDebouncedSearch(value.toLowerCase().trim()); + }, 300); + setDebounceTimer(timer); + }, [debounceTimer]); + + const clearSearch = useCallback(() => { + setSearchQuery(''); + setDebouncedSearch(''); + }, []); const params = { status: statusFilter, @@ -22,6 +40,19 @@ export function BountyGrid() { const allBounties = data?.pages.flatMap((p) => p.items) ?? []; + // Client-side search filtering + const filteredBounties = useMemo(() => { + if (!debouncedSearch) return allBounties; + return allBounties.filter((bounty) => { + const title = (bounty.title || '').toLowerCase(); + const desc = (bounty.description || '').toLowerCase(); + const skills = (bounty.skills || []).join(' ').toLowerCase(); + const category = (bounty.category || '').toLowerCase(); + const query = debouncedSearch; + return title.includes(query) || desc.includes(query) || skills.includes(query) || category.includes(query); + }); + }, [allBounties, debouncedSearch]); + return (
@@ -53,6 +84,27 @@ export function BountyGrid() {
+ {/* Search bar */} +
+ + handleSearchChange(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 +145,23 @@ export function BountyGrid() { )} {/* Empty state */} - {!isLoading && !isError && allBounties.length === 0 && ( + {!isLoading && !isError && filteredBounties.length === 0 && (
-

No bounties found

+

+ {debouncedSearch ? 'No bounties match your search' : 'No bounties found'} +

- {activeSkill !== 'All' ? `Try a different language filter.` : 'Check back soon for new bounties.'} + {debouncedSearch + ? 'Try different keywords or clear the search.' + : 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) => (