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..1bf302892 100644 --- a/frontend/src/components/bounty/BountyGrid.tsx +++ b/frontend/src/components/bounty/BountyGrid.tsx @@ -1,16 +1,50 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useRef, 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'; +import type { Bounty } from '../../types/bounty'; const FILTER_SKILLS = ['All', 'TypeScript', 'Rust', 'Solidity', 'Python', 'Go', 'JavaScript']; +/** + * Debounce a value by the given delay (ms). + * Returns the debounced value, which updates only after `delay` ms of inactivity. + */ +function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} + +/** + * Client-side search filter: match bounty title, description, and skills. + */ +function searchBounties(bounties: Bounty[], query: string): Bounty[] { + if (!query.trim()) return bounties; + const q = query.toLowerCase().trim(); + return bounties.filter( + (b) => + b.title.toLowerCase().includes(q) || + (b.description && b.description.toLowerCase().includes(q)) || + (b.skills && b.skills.some((s) => s.toLowerCase().includes(q))) + ); +} + export function BountyGrid() { const [activeSkill, setActiveSkill] = useState('All'); const [statusFilter, setStatusFilter] = useState('open'); + const [searchQuery, setSearchQuery] = useState(''); + const inputRef = useRef(null); + + const debouncedSearch = useDebounce(searchQuery, 300); const params = { status: statusFilter, @@ -22,6 +56,17 @@ export function BountyGrid() { const allBounties = data?.pages.flatMap((p) => p.items) ?? []; + // Client-side search filtering + const filteredBounties = useMemo( + () => searchBounties(allBounties, debouncedSearch), + [allBounties, debouncedSearch] + ); + + const handleClear = () => { + setSearchQuery(''); + inputRef.current?.focus(); + }; + return (
@@ -53,21 +98,47 @@ export function BountyGrid() {
- {/* Filter pills */} -
- {FILTER_SKILLS.map((skill) => ( - - ))} + {/* Search bar + Filter pills */} +
+ {/* Search bar */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search by title, description, or skills..." + className="w-full bg-forge-800 border border-border rounded-lg pl-10 pr-9 py-2 text-sm text-text-secondary placeholder:text-text-muted focus:border-emerald outline-none transition-colors duration-150" + aria-label="Search bounties" + /> + {searchQuery && ( + + )} +
+ + {/* Filter pills */} +
+ {FILTER_SKILLS.map((skill) => ( + + ))} +
{/* Loading state */} @@ -93,17 +164,21 @@ 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.'} + {debouncedSearch + ? `No results for "${debouncedSearch}". Try a different search term.` + : 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) => ( @@ -135,4 +210,4 @@ export function BountyGrid() {
); -} +} \ No newline at end of file diff --git a/test-pr-permission.txt b/test-pr-permission.txt new file mode 100644 index 000000000..9daeafb98 --- /dev/null +++ b/test-pr-permission.txt @@ -0,0 +1 @@ +test