diff --git a/frontend/src/components/bounty/BountyGrid.test.tsx b/frontend/src/components/bounty/BountyGrid.test.tsx new file mode 100644 index 000000000..9dcf6adf6 --- /dev/null +++ b/frontend/src/components/bounty/BountyGrid.test.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { BountyGrid } from './BountyGrid'; +import type { Bounty } from '../../types/bounty'; +import { useInfiniteBounties } from '../../hooks/useBounties'; + +vi.mock('../../hooks/useBounties', () => ({ + useInfiniteBounties: vi.fn(), +})); + +beforeAll(() => { + class MockIntersectionObserver { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + } + + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); +}); + +const baseBounty: Bounty = { + id: '1', + title: 'Add Stripe checkout', + description: 'Implement payment flow', + status: 'open', + tier: 'T1', + reward_amount: 100000, + reward_token: 'FNDRY', + skills: ['TypeScript', 'React'], + submission_count: 0, + created_at: '2026-05-01T00:00:00Z', +}; + +function renderGrid(items: Bounty[]) { + vi.mocked(useInfiniteBounties).mockReturnValue({ + data: { pages: [{ items, total: items.length, limit: 12, offset: 0 }], pageParams: [0] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + isError: false, + } as ReturnType); + + render( + + + , + ); +} + +describe('BountyGrid search', () => { + it('filters visible bounties by title, description, and tags', async () => { + const user = userEvent.setup(); + renderGrid([ + baseBounty, + { + ...baseBounty, + id: '2', + title: 'Rust parser cleanup', + description: 'Improve parser diagnostics', + skills: ['Rust'], + }, + ]); + + expect(screen.getByText('Add Stripe checkout')).toBeInTheDocument(); + expect(screen.getByText('Rust parser cleanup')).toBeInTheDocument(); + + await user.type(screen.getByLabelText('Search bounties'), 'stripe'); + + await waitFor(() => { + expect(screen.getByText('Add Stripe checkout')).toBeInTheDocument(); + expect(screen.queryByText('Rust parser cleanup')).not.toBeInTheDocument(); + }); + + await user.clear(screen.getByLabelText('Search bounties')); + await user.type(screen.getByLabelText('Search bounties'), 'rust'); + + await waitFor(() => { + expect(screen.queryByText('Add Stripe checkout')).not.toBeInTheDocument(); + expect(screen.getByText('Rust parser cleanup')).toBeInTheDocument(); + }); + }); + + it('clears the search query', async () => { + const user = userEvent.setup(); + renderGrid([baseBounty]); + + await user.type(screen.getByLabelText('Search bounties'), 'missing'); + await waitFor(() => expect(screen.getByText('No bounties found')).toBeInTheDocument()); + + await user.click(screen.getByLabelText('Clear bounty search')); + + await waitFor(() => { + expect(screen.getByText('Add Stripe checkout')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/bounty/BountyGrid.tsx b/frontend/src/components/bounty/BountyGrid.tsx index 7709ab94c..f3a3e51cf 100644 --- a/frontend/src/components/bounty/BountyGrid.tsx +++ b/frontend/src/components/bounty/BountyGrid.tsx @@ -1,16 +1,46 @@ -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']; +function bountyMatchesSearch(bounty: Bounty, query: string) { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return true; + + const searchableText = [ + bounty.title, + bounty.description, + bounty.category, + bounty.status, + bounty.tier, + bounty.reward_token, + bounty.org_name, + bounty.repo_name, + ...(bounty.skills ?? []), + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + return searchableText.includes(normalizedQuery); +} + export function BountyGrid() { const [activeSkill, setActiveSkill] = useState('All'); const [statusFilter, setStatusFilter] = useState('open'); + const [searchQuery, setSearchQuery] = useState(''); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); + + useEffect(() => { + const timeoutId = window.setTimeout(() => setDebouncedSearchQuery(searchQuery), 250); + return () => window.clearTimeout(timeoutId); + }, [searchQuery]); const params = { status: statusFilter, @@ -21,6 +51,10 @@ export function BountyGrid() { useInfiniteBounties(params); const allBounties = data?.pages.flatMap((p) => p.items) ?? []; + const visibleBounties = useMemo( + () => allBounties.filter((bounty) => bountyMatchesSearch(bounty, debouncedSearchQuery)), + [allBounties, debouncedSearchQuery], + ); return (
@@ -53,6 +87,29 @@ export function BountyGrid() { + {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search by title, description, or tags" + aria-label="Search bounties" + className="w-full rounded-lg border border-border bg-forge-800 py-2 pl-10 pr-10 text-sm text-text-primary placeholder:text-text-muted outline-none transition-colors duration-150 focus:border-emerald" + /> + {searchQuery && ( + + )} +
+ {/* Filter pills */}
{FILTER_SKILLS.map((skill) => ( @@ -93,17 +150,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.'} + {debouncedSearchQuery + ? 'Try a different search term or clear the search.' + : 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..0f867a320 --- /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, y: 12 }, + animate: { opacity: 1, y: 0, transition: { duration: 0.25, ease: 'easeOut' } }, +}; + +export const pageTransition: Variants = { + initial: { opacity: 0 }, + animate: { opacity: 1, transition: { duration: 0.2, ease: 'easeOut' } }, + exit: { opacity: 0, transition: { duration: 0.15, ease: 'easeIn' } }, +}; + +export const staggerContainer: Variants = { + initial: {}, + animate: { + transition: { + staggerChildren: 0.06, + }, + }, +}; + +export const staggerItem: Variants = { + initial: { opacity: 0, y: 12 }, + animate: { opacity: 1, y: 0, transition: { duration: 0.22, ease: 'easeOut' } }, +}; + +export const slideInRight: Variants = { + initial: { opacity: 0, x: 16 }, + animate: { opacity: 1, x: 0, transition: { duration: 0.25, ease: 'easeOut' } }, +}; + +export const cardHover: Variants = { + rest: { y: 0 }, + hover: { y: -3, transition: { duration: 0.18, ease: 'easeOut' } }, +}; + +export const buttonHover: Variants = { + rest: { scale: 1 }, + hover: { scale: 1.02, transition: { duration: 0.15, ease: 'easeOut' } }, + tap: { scale: 0.98 }, +}; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 000000000..45ed8f3ee --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,58 @@ +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', + Tailwind: '#38bdf8', +}; + +export function formatCurrency(amount: number, token: RewardToken = 'FNDRY') { + const formattedAmount = new Intl.NumberFormat('en-US', { + maximumFractionDigits: token === 'USDC' ? 2 : 0, + }).format(amount); + + return token === 'USDC' ? `$${formattedAmount}` : `${formattedAmount} ${token}`; +} + +export function timeAgo(value: string) { + const timestamp = new Date(value).getTime(); + if (Number.isNaN(timestamp)) return 'Unknown'; + + const diffSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); + if (diffSeconds < 60) return 'Just now'; + + const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [ + ['year', 31536000], + ['month', 2592000], + ['week', 604800], + ['day', 86400], + ['hour', 3600], + ['minute', 60], + ]; + const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); + const [unit, secondsPerUnit] = units.find(([, seconds]) => diffSeconds >= seconds) ?? ['minute', 60]; + + return formatter.format(-Math.floor(diffSeconds / secondsPerUnit), unit); +} + +export function timeLeft(value: string) { + const timestamp = new Date(value).getTime(); + if (Number.isNaN(timestamp)) return 'Unknown'; + + const diffSeconds = Math.floor((timestamp - Date.now()) / 1000); + if (diffSeconds <= 0) return 'Expired'; + + const days = Math.floor(diffSeconds / 86400); + if (days > 0) return `${days}d left`; + + const hours = Math.floor(diffSeconds / 3600); + if (hours > 0) return `${hours}h left`; + + const minutes = Math.max(1, Math.floor(diffSeconds / 60)); + return `${minutes}m left`; +}