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
63 changes: 59 additions & 4 deletions frontend/src/components/bounty/BountyGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
import React, { useState } from 'react';
import React, { useState, useMemo, useCallback, useRef, useEffect } 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';

const FILTER_SKILLS = ['All', 'TypeScript', 'Rust', 'Solidity', 'Python', 'Go', 'JavaScript'];

function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}

export function BountyGrid() {
const [activeSkill, setActiveSkill] = useState<string>('All');
const [statusFilter, setStatusFilter] = useState<string>('open');
const [searchQuery, setSearchQuery] = useState<string>('');
const searchInputRef = useRef<HTMLInputElement>(null);

const debouncedSearch = useDebounce(searchQuery, 300);

const params = {
status: statusFilter,
Expand All @@ -20,7 +33,23 @@ export function BountyGrid() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError } =
useInfiniteBounties(params);

const allBounties = data?.pages.flatMap((p) => p.items) ?? [];
const allBountiesRaw = data?.pages.flatMap((p) => p.items) ?? [];

const allBounties = useMemo(() => {
if (!debouncedSearch.trim()) return allBountiesRaw;
const query = debouncedSearch.toLowerCase().trim();
return allBountiesRaw.filter(
(bounty) =>
bounty.title.toLowerCase().includes(query) ||
bounty.description.toLowerCase().includes(query) ||
bounty.skills.some((s) => s.toLowerCase().includes(query))
);
}, [allBountiesRaw, debouncedSearch]);

const handleClearSearch = useCallback(() => {
setSearchQuery('');
searchInputRef.current?.focus();
}, []);

return (
<section id="bounties" className="py-16 md:py-24">
Expand Down Expand Up @@ -53,6 +82,28 @@ 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
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search bounties by title, description, or skill..."
className="w-full pl-10 pr-10 py-2.5 rounded-lg bg-forge-800 border border-border text-sm text-text-primary placeholder:text-text-muted focus:border-emerald focus:ring-1 focus:ring-emerald outline-none transition-all duration-150"
/>
{searchQuery && (
<button
onClick={handleClearSearch}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded text-text-muted hover:text-text-primary transition-colors duration-150"
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 @@ -97,7 +148,11 @@ export function BountyGrid() {
<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-sm">
{activeSkill !== 'All' ? `Try a different language filter.` : 'Check back soon for new bounties.'}
{debouncedSearch
? `No results for "${debouncedSearch}". Try a different search term.`
: activeSkill !== 'All'
? `Try a different language filter.`
: 'Check back soon for new bounties.'}
</p>
</div>
)}
Expand Down