Skip to content
Open
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions frontend/src/api/bounties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface BountiesListParams {
skill?: string;
tier?: string;
reward_token?: string;
search?: string;
}

export interface BountiesListResponse {
Expand Down
53 changes: 46 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, 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';
Expand All @@ -11,6 +11,14 @@ 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('');
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,
Expand All @@ -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 (
<section id="bounties" className="py-16 md:py-24">
<div className="max-w-7xl mx-auto px-4">
{/* Header row */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<h2 className="font-sans text-2xl font-semibold text-text-primary">Open Bounties</h2>
<div className="flex items-center gap-2">
<Link
Expand All @@ -53,6 +72,26 @@ 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) => 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 && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-secondary transition-colors"
>
<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 +132,25 @@ 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-sm">
{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.'}
</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