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
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
/lib/
/lib64/
parts/
sdist/
var/
Expand Down
111 changes: 111 additions & 0 deletions frontend/src/__tests__/bounty-grid-search.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement> & Record<string, unknown>) => <div {...props}>{children}</div>,
},
}));

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<Bounty>;

function bounty(overrides: Partial<Bounty>): 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<typeof useInfiniteBounties>);
});

it('filters bounty cards by debounced title, description, and skills search', () => {
render(
<MemoryRouter>
<BountyGrid />
</MemoryRouter>,
);

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

{/* Search */}
<div className="mb-5">
<label htmlFor="bounty-search" className="sr-only">
Search bounties
</label>
<div className="relative max-w-xl">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted pointer-events-none" />
<input
id="bounty-search"
type="search"
value={searchTerm}
onChange={(event) => 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 && (
<button
type="button"
onClick={() => {
setSearchTerm('');
setDebouncedSearch('');
}}
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex h-7 w-7 items-center justify-center rounded-md text-text-muted transition-colors duration-150 hover:bg-forge-800 hover:text-text-primary"
aria-label="Clear search"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>

{/* Filter pills */}
<div className="flex items-center gap-2 flex-wrap mb-8">
{FILTER_SKILLS.map((skill) => (
Expand Down Expand Up @@ -93,25 +156,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.'}
{debouncedSearch
? 'Try a different search term.'
: 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 },
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 } },
};
63 changes: 63 additions & 0 deletions frontend/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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',
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`;
}