Skip to content
Open
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
72 changes: 65 additions & 7 deletions frontend/src/components/bounty/BountyGrid.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,6 +11,24 @@ const FILTER_SKILLS = ['All', 'TypeScript', 'Rust', 'Solidity', 'Python', 'Go',
export function BountyGrid() {
const [activeSkill, setActiveSkill] = useState<string>('All');
const [statusFilter, setStatusFilter] = useState<string>('open');
const [searchQuery, setSearchQuery] = useState<string>('');
const [debouncedSearch, setDebouncedSearch] = useState<string>('');
const [debounceTimer, setDebounceTimer] = useState<ReturnType<typeof setTimeout> | 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,
Expand All @@ -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 (
<section id="bounties" className="py-16 md:py-24">
<div className="max-w-7xl mx-auto px-4">
Expand Down Expand Up @@ -53,6 +84,27 @@ export function BountyGrid() {
</div>
</div>

{/* Search bar */}
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={(e) => 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 && (
<button
onClick={clearSearch}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-secondary transition-colors"
aria-label="Clear search"
>
<X className="w-4 h-4" />
</button>
)}
</div>

{/* Filter pills */}
<div className="flex items-center gap-2 flex-wrap mb-8">
{FILTER_SKILLS.map((skill) => (
Expand Down Expand Up @@ -93,25 +145,31 @@ export function BountyGrid() {
)}

{/* Empty state */}
{!isLoading && !isError && allBounties.length === 0 && (
{!isLoading && !isError && filteredBounties.length === 0 && (
<div className="text-center py-16">
<p className="text-text-muted text-lg mb-2">No bounties found</p>
<p className="text-text-muted text-lg mb-2">
{debouncedSearch ? 'No bounties match your search' : 'No bounties found'}
</p>
<p className="text-text-muted text-sm">
{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.'}
</p>
</div>
)}

{/* Bounty grid */}
{!isLoading && allBounties.length > 0 && (
{!isLoading && filteredBounties.length > 0 && (
<motion.div
variants={staggerContainer}
initial="initial"
whileInView="animate"
viewport={{ once: true, margin: '-50px' }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5"
>
{allBounties.map((bounty) => (
{filteredBounties.map((bounty) => (
<motion.div key={bounty.id} variants={staggerItem}>
<BountyCard bounty={bounty} />
</motion.div>
Expand Down
Loading