From a4ebe3e8d1cdd912cab73e02b536629aa235e970 Mon Sep 17 00:00:00 2001 From: yjr <24300180030@m.fudan.edu.cn> Date: Tue, 12 May 2026 17:32:23 +0800 Subject: [PATCH] Add bounty search filtering --- .gitignore | 4 +- .../src/__tests__/bounty-grid-search.test.tsx | 111 ++++++++++++++++++ frontend/src/components/bounty/BountyGrid.tsx | 79 ++++++++++++- frontend/src/lib/animations.ts | 42 +++++++ frontend/src/lib/utils.ts | 63 ++++++++++ 5 files changed, 291 insertions(+), 8 deletions(-) create mode 100644 frontend/src/__tests__/bounty-grid-search.test.tsx create mode 100644 frontend/src/lib/animations.ts create mode 100644 frontend/src/lib/utils.ts diff --git a/.gitignore b/.gitignore index 36fca7e4f..10cc267fa 100644 --- a/.gitignore +++ b/.gitignore @@ -25,8 +25,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ diff --git a/frontend/src/__tests__/bounty-grid-search.test.tsx b/frontend/src/__tests__/bounty-grid-search.test.tsx new file mode 100644 index 000000000..4f62ed18e --- /dev/null +++ b/frontend/src/__tests__/bounty-grid-search.test.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BountyGrid } from '../components/bounty/BountyGrid'; +import { useInfiniteBounties } from '../hooks/useBounties'; +import type { Bounty } from '../types/bounty'; + +vi.mock('framer-motion', () => ({ + motion: { + div: ({ + children, + animate, + exit, + initial, + layout, + transition, + variants, + viewport, + whileHover, + whileInView, + whileTap, + ...props + }: React.HTMLAttributes & Record) =>
{children}
, + }, +})); + +vi.mock('../hooks/useBounties', () => ({ + useInfiniteBounties: vi.fn(), +})); + +const baseBounty = { + status: 'open', + tier: 'T1', + reward_amount: 100_000, + reward_token: 'FNDRY', + submission_count: 0, + created_at: '2026-05-01T00:00:00.000Z', + github_issue_url: 'https://github.com/SolFoundry/solfoundry/issues/1', +} satisfies Partial; + +function bounty(overrides: Partial): Bounty { + return { + ...baseBounty, + id: overrides.id ?? 'bounty-id', + title: overrides.title ?? 'Bounty title', + description: overrides.description ?? 'Bounty description', + skills: overrides.skills ?? [], + ...overrides, + } as Bounty; +} + +describe('BountyGrid search', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(useInfiniteBounties).mockReturnValue({ + data: { + pages: [ + { + items: [ + bounty({ + id: 'react', + title: 'Add toast notification system', + description: 'Build a reusable UI toast component.', + skills: ['React', 'TypeScript'], + }), + bounty({ + id: 'rust', + title: 'Optimize Rust verifier', + description: 'Improve backend proof validation.', + skills: ['Rust'], + }), + ], + total: 2, + limit: 12, + offset: 0, + }, + ], + pageParams: [0], + }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + isError: false, + } as unknown as ReturnType); + }); + + it('filters bounty cards by debounced title, description, and skills search', () => { + render( + + + , + ); + + expect(screen.getByText('Add toast notification system')).toBeInTheDocument(); + expect(screen.getByText('Optimize Rust verifier')).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Search bounties'), { + target: { value: 'rust' }, + }); + act(() => vi.advanceTimersByTime(250)); + + expect(screen.getByText('Optimize Rust verifier')).toBeInTheDocument(); + expect(screen.queryByText('Add toast notification system')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Clear search')); + expect(screen.getByText('Add toast notification system')).toBeInTheDocument(); + expect(screen.getByText('Optimize Rust verifier')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/bounty/BountyGrid.tsx b/frontend/src/components/bounty/BountyGrid.tsx index 7709ab94c..110e77bec 100644 --- a/frontend/src/components/bounty/BountyGrid.tsx +++ b/frontend/src/components/bounty/BountyGrid.tsx @@ -1,16 +1,27 @@ -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } 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']; export function BountyGrid() { const [activeSkill, setActiveSkill] = useState('All'); const [statusFilter, setStatusFilter] = useState('open'); + const [searchTerm, setSearchTerm] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + + useEffect(() => { + const timeout = window.setTimeout(() => { + setDebouncedSearch(searchTerm.trim().toLowerCase()); + }, 250); + + return () => window.clearTimeout(timeout); + }, [searchTerm]); const params = { status: statusFilter, @@ -21,6 +32,27 @@ export function BountyGrid() { useInfiniteBounties(params); const allBounties = data?.pages.flatMap((p) => p.items) ?? []; + const visibleBounties = useMemo(() => { + if (!debouncedSearch) return allBounties; + + return allBounties.filter((bounty: Bounty) => { + const searchableText = [ + bounty.title, + bounty.description, + bounty.category, + bounty.tier, + bounty.status, + bounty.org_name, + bounty.repo_name, + ...(bounty.skills ?? []), + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + return searchableText.includes(debouncedSearch); + }); + }, [allBounties, debouncedSearch]); return (
@@ -53,6 +85,37 @@ export function BountyGrid() { + {/* Search */} +
+ +
+ + setSearchTerm(event.target.value)} + placeholder="Search bounties" + className="w-full h-10 rounded-lg border border-border bg-forge-900 pl-10 pr-10 text-sm text-text-primary placeholder:text-text-muted outline-none transition-colors duration-150 focus:border-emerald" + /> + {searchTerm && ( + + )} +
+
+ {/* Filter pills */}
{FILTER_SKILLS.map((skill) => ( @@ -93,17 +156,21 @@ export function BountyGrid() { )} {/* Empty state */} - {!isLoading && !isError && allBounties.length === 0 && ( + {!isLoading && !isError && visibleBounties.length === 0 && (

No bounties found

- {activeSkill !== 'All' ? `Try a different language filter.` : 'Check back soon for new bounties.'} + {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 && visibleBounties.length > 0 && ( - {allBounties.map((bounty) => ( + {visibleBounties.map((bounty) => ( diff --git a/frontend/src/lib/animations.ts b/frontend/src/lib/animations.ts new file mode 100644 index 000000000..ee7456acf --- /dev/null +++ b/frontend/src/lib/animations.ts @@ -0,0 +1,42 @@ +import type { Variants } from 'framer-motion'; + +export const fadeIn: Variants = { + initial: { opacity: 0 }, + animate: { opacity: 1, transition: { duration: 0.2 } }, +}; + +export const pageTransition: Variants = { + initial: { opacity: 0, y: 12 }, + animate: { opacity: 1, y: 0, transition: { duration: 0.2 } }, + exit: { opacity: 0, y: -8, transition: { duration: 0.15 } }, +}; + +export const staggerContainer: Variants = { + initial: {}, + animate: { + transition: { + staggerChildren: 0.05, + }, + }, +}; + +export const staggerItem: Variants = { + initial: { opacity: 0, y: 10 }, + animate: { opacity: 1, y: 0, transition: { duration: 0.2 } }, +}; + +export const cardHover: Variants = { + rest: { y: 0 }, + hover: { y: -3, transition: { duration: 0.15 } }, +}; + +export const buttonHover: Variants = { + rest: { scale: 1 }, + hover: { scale: 1.03, transition: { duration: 0.15 } }, + tap: { scale: 0.98, transition: { duration: 0.1 } }, +}; + +export const slideInRight: Variants = { + initial: { opacity: 0, x: 20 }, + animate: { opacity: 1, x: 0, transition: { duration: 0.2 } }, +}; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 000000000..1c4470e37 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,63 @@ +import type { RewardToken } from '../types/bounty'; + +export const LANG_COLORS: Record = { + TypeScript: '#3178c6', + JavaScript: '#f7df1e', + Rust: '#dea584', + Solidity: '#627eea', + Python: '#3776ab', + Go: '#00add8', + React: '#61dafb', + SQL: '#f29111', +}; + +export function formatCurrency(amount: number, token: RewardToken | string = 'FNDRY'): string { + const formatted = new Intl.NumberFormat('en-US', { + maximumFractionDigits: amount >= 100 ? 0 : 2, + }).format(amount); + + return token === 'USDC' ? `$${formatted}` : `${formatted} ${token}`; +} + +export function timeAgo(date: string): string { + const timestamp = new Date(date).getTime(); + if (Number.isNaN(timestamp)) return 'recently'; + + const diffSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); + if (diffSeconds < 60) return 'just now'; + + const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [ + ['year', 365 * 24 * 60 * 60], + ['month', 30 * 24 * 60 * 60], + ['week', 7 * 24 * 60 * 60], + ['day', 24 * 60 * 60], + ['hour', 60 * 60], + ['minute', 60], + ]; + + const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); + for (const [unit, secondsPerUnit] of units) { + if (diffSeconds >= secondsPerUnit) { + return formatter.format(-Math.floor(diffSeconds / secondsPerUnit), unit); + } + } + + return 'just now'; +} + +export function timeLeft(date: string): string { + const timestamp = new Date(date).getTime(); + if (Number.isNaN(timestamp)) return 'No deadline'; + + const diffSeconds = Math.floor((timestamp - Date.now()) / 1000); + if (diffSeconds <= 0) return 'Expired'; + + const days = Math.floor(diffSeconds / (24 * 60 * 60)); + if (days > 0) return `${days}d left`; + + const hours = Math.floor(diffSeconds / (60 * 60)); + if (hours > 0) return `${hours}h left`; + + const minutes = Math.floor(diffSeconds / 60); + return `${Math.max(1, minutes)}m left`; +}