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
100 changes: 100 additions & 0 deletions frontend/src/components/bounty/BountyGrid.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useInfiniteBounties>);

render(
<MemoryRouter>
<BountyGrid />
</MemoryRouter>,
);
}

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();
});
});
});
73 changes: 67 additions & 6 deletions frontend/src/components/bounty/BountyGrid.tsx
Original file line number Diff line number Diff line change
@@ -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<string>('All');
const [statusFilter, setStatusFilter] = useState<string>('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,
Expand All @@ -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 (
<section id="bounties" className="py-16 md:py-24">
Expand Down Expand Up @@ -53,6 +87,29 @@ export function BountyGrid() {
</div>
</div>

{/* Search */}
<div className="relative mb-4 max-w-xl">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-muted" />
<input
type="search"
value={searchQuery}
onChange={(e) => 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 && (
<button
type="button"
onClick={() => setSearchQuery('')}
aria-label="Clear bounty search"
className="absolute right-2 top-1/2 inline-flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md text-text-muted transition-colors duration-150 hover:bg-forge-700 hover:text-text-primary"
>
<X className="h-4 w-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 +150,29 @@ export function BountyGrid() {
)}

{/* Empty state */}
{!isLoading && !isError && allBounties.length === 0 && (
{!isLoading && !isError && visibleBounties.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.'}
{debouncedSearchQuery
? 'Try a different search term or clear the search.'
: activeSkill !== 'All'
? 'Try a different language filter.'
: 'Check back soon for new bounties.'}
</p>
</div>
)}

{/* Bounty grid */}
{!isLoading && allBounties.length > 0 && (
{!isLoading && visibleBounties.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) => (
{visibleBounties.map((bounty) => (
<motion.div key={bounty.id} variants={staggerItem}>
<BountyCard bounty={bounty} />
</motion.div>
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/lib/animations.ts
Original file line number Diff line number Diff line change
@@ -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 },
};
58 changes: 58 additions & 0 deletions frontend/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { RewardToken } from '../types/bounty';

export const LANG_COLORS: Record<string, string> = {
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`;
}