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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ downloads/
eggs/
.eggs/
lib/
!frontend/src/lib/
!frontend/src/lib/**
lib64/
parts/
sdist/
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/__tests__/loading-skeletons.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import {
BountyDetailSkeleton,
BountyGridSkeleton,
LeaderboardSkeleton,
ProfileBountyListSkeleton,
} from '../components/ui/Skeletons';

describe('loading skeletons', () => {
it('renders content-shaped bounty card placeholders with shimmer animation', () => {
render(<BountyGridSkeleton count={3} />);

expect(screen.getByRole('status', { name: /loading bounties/i })).toBeInTheDocument();
expect(screen.getAllByTestId('bounty-card-skeleton')).toHaveLength(3);
expect(document.querySelector('.animate-shimmer')).toBeInTheDocument();
});

it('renders leaderboard row placeholders instead of a spinner-only state', () => {
render(<LeaderboardSkeleton />);

expect(screen.getByRole('status', { name: /loading leaderboard/i })).toBeInTheDocument();
expect(screen.getAllByTestId('leaderboard-row-skeleton')).toHaveLength(5);
});

it('renders profile and detail placeholders with accessible status labels', () => {
const { rerender } = render(<ProfileBountyListSkeleton />);
expect(screen.getByRole('status', { name: /loading profile bounties/i })).toBeInTheDocument();
expect(screen.getAllByTestId('profile-bounty-skeleton')).toHaveLength(4);

rerender(<BountyDetailSkeleton />);
expect(screen.getByRole('status', { name: /loading bounty detail/i })).toBeInTheDocument();
});
});
12 changes: 2 additions & 10 deletions frontend/src/components/bounty/BountyGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ChevronDown, Loader2, Plus } from 'lucide-react';
import { BountyCard } from './BountyCard';
import { useInfiniteBounties } from '../../hooks/useBounties';
import { staggerContainer, staggerItem } from '../../lib/animations';
import { BountyGridSkeleton } from '../ui/Skeletons';

const FILTER_SKILLS = ['All', 'TypeScript', 'Rust', 'Solidity', 'Python', 'Go', 'JavaScript'];

Expand Down Expand Up @@ -72,16 +73,7 @@ export function BountyGrid() {

{/* Loading state */}
{isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-52 rounded-xl border border-border bg-forge-900 overflow-hidden"
>
<div className="h-full bg-gradient-to-r from-forge-900 via-forge-800 to-forge-900 bg-[length:200%_100%] animate-shimmer" />
</div>
))}
</div>
<BountyGridSkeleton count={6} />
)}

{/* Error state */}
Expand Down
7 changes: 2 additions & 5 deletions frontend/src/components/home/FeaturedBounties.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ArrowRight } from 'lucide-react';
import { staggerContainer, staggerItem } from '../../lib/animations';
import { useBounties } from '../../hooks/useBounties';
import { BountyCard } from '../bounty/BountyCard';
import { BountyGridSkeleton } from '../ui/Skeletons';

export function FeaturedBounties() {
const { data, isLoading, isError } = useBounties({ limit: 4, status: 'open' });
Expand Down Expand Up @@ -37,11 +38,7 @@ export function FeaturedBounties() {
</motion.div>

{isLoading && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border bg-forge-900 h-48 animate-pulse" />
))}
</div>
<BountyGridSkeleton count={4} className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4" />
)}

{isError && (
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/profile/ProfileDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useBounties } from '../../hooks/useBounties';
import { timeAgo, formatCurrency } from '../../lib/utils';
import { fadeIn, staggerContainer, staggerItem } from '../../lib/animations';
import type { Bounty } from '../../types/bounty';
import { ProfileBountyListSkeleton } from '../ui/Skeletons';

const TABS = ['My Bounties', 'My Submissions', 'Earnings', 'Settings'] as const;
type Tab = typeof TABS[number];
Expand Down Expand Up @@ -35,7 +36,7 @@ function BountyStatusBadge({ status }: { status: string }) {

function MyBountiesTab({ bounties, loading }: { bounties: Bounty[]; loading: boolean }) {
if (loading) {
return <div className="text-text-muted text-sm py-8 text-center">Loading...</div>;
return <ProfileBountyListSkeleton />;
}
if (!bounties.length) {
return (
Expand Down
179 changes: 179 additions & 0 deletions frontend/src/components/ui/Skeletons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import React from 'react';

function SkeletonBlock({ className = '' }: { className?: string }) {
return (
<div
aria-hidden="true"
className={`overflow-hidden rounded bg-forge-800 ${className}`}
>
<div className="h-full w-full bg-gradient-to-r from-forge-800 via-forge-700 to-forge-800 bg-[length:200%_100%] animate-shimmer" />
</div>
);
}

export function BountyCardSkeleton() {
return (
<div
aria-hidden="true"
data-testid="bounty-card-skeleton"
className="rounded-xl border border-border bg-forge-900 p-5"
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2 min-w-0 flex-1">
<SkeletonBlock className="h-5 w-5 rounded-full flex-shrink-0" />
<SkeletonBlock className="h-3 w-36 max-w-full" />
</div>
<SkeletonBlock className="h-5 w-10 rounded-full" />
</div>

<div className="mt-4 space-y-2">
<SkeletonBlock className="h-4 w-11/12" />
<SkeletonBlock className="h-4 w-2/3" />
</div>

<div className="mt-4 flex items-center gap-3">
<SkeletonBlock className="h-3 w-20 rounded-full" />
<SkeletonBlock className="h-3 w-14 rounded-full" />
<SkeletonBlock className="h-3 w-16 rounded-full" />
</div>

<div className="mt-5 border-t border-border/50 pt-4">
<div className="flex items-center justify-between gap-4">
<SkeletonBlock className="h-5 w-24" />
<div className="flex items-center gap-3">
<SkeletonBlock className="h-3 w-12 rounded-full" />
<SkeletonBlock className="h-3 w-16 rounded-full" />
</div>
</div>
</div>
</div>
);
}

export function BountyGridSkeleton({ count = 6, className = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5' }: { count?: number; className?: string }) {
return (
<div
role="status"
aria-label="Loading bounties"
className={className}
>
{Array.from({ length: count }).map((_, index) => (
<BountyCardSkeleton key={index} />
))}
</div>
);
}

export function LeaderboardSkeleton() {
return (
<div role="status" aria-label="Loading leaderboard" className="space-y-8">
<div className="flex items-end justify-center gap-4 md:gap-6">
{[0, 1, 2].map((index) => (
<div
key={index}
className={`rounded-xl border border-border bg-forge-900 px-6 ${index === 1 ? 'py-8' : 'py-6'} min-w-[140px] flex flex-col items-center`}
>
<SkeletonBlock className={`${index === 1 ? 'h-14 w-14' : 'h-12 w-12'} rounded-full`} />
<SkeletonBlock className="mt-3 h-4 w-24" />
<SkeletonBlock className="mt-2 h-3 w-20" />
<SkeletonBlock className="mt-3 h-5 w-24" />
</div>
))}
</div>

<div className="max-w-4xl mx-auto rounded-xl border border-border bg-forge-900 overflow-hidden">
<div className="flex items-center px-4 py-3 border-b border-border/50">
<SkeletonBlock className="h-3 w-12" />
<SkeletonBlock className="ml-8 h-3 w-24" />
<SkeletonBlock className="ml-auto h-3 w-20" />
</div>
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} data-testid="leaderboard-row-skeleton" className="flex items-center gap-3 px-4 py-3 border-b border-border/30 last:border-b-0">
<SkeletonBlock className="h-4 w-10" />
<SkeletonBlock className="h-6 w-6 rounded-full" />
<div className="flex-1 space-y-1.5">
<SkeletonBlock className="h-3 w-32" />
<SkeletonBlock className="h-2.5 w-20" />
</div>
<SkeletonBlock className="h-4 w-16" />
<SkeletonBlock className="h-4 w-20 hidden sm:block" />
</div>
))}
</div>
</div>
);
}

export function ProfileBountyListSkeleton() {
return (
<div role="status" aria-label="Loading profile bounties" className="space-y-2">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} data-testid="profile-bounty-skeleton" className="flex items-center gap-4 px-4 py-3 rounded-lg bg-forge-900 border border-border">
<div className="flex-1 min-w-0 space-y-2">
<SkeletonBlock className="h-4 w-3/4" />
<SkeletonBlock className="h-3 w-24" />
</div>
<SkeletonBlock className="h-4 w-20" />
<SkeletonBlock className="h-5 w-16 rounded-full" />
<SkeletonBlock className="h-3 w-10" />
</div>
))}
</div>
);
}

export function BountyDetailSkeleton() {
return (
<div role="status" aria-label="Loading bounty detail" className="max-w-4xl mx-auto px-4 py-8">
<SkeletonBlock className="mb-6 h-4 w-32" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<div className="rounded-xl border border-border bg-forge-900 p-6">
<div className="flex items-start justify-between gap-4 mb-5">
<div className="flex-1 space-y-3">
<SkeletonBlock className="h-3 w-44" />
<SkeletonBlock className="h-7 w-4/5" />
</div>
<SkeletonBlock className="h-10 w-10 rounded-lg" />
</div>
<div className="flex gap-3 mb-5">
<SkeletonBlock className="h-3 w-20 rounded-full" />
<SkeletonBlock className="h-3 w-16 rounded-full" />
</div>
<div className="space-y-2">
<SkeletonBlock className="h-4 w-full" />
<SkeletonBlock className="h-4 w-11/12" />
<SkeletonBlock className="h-4 w-2/3" />
</div>
</div>

<div className="rounded-xl border border-border bg-forge-900 p-6 space-y-3">
<SkeletonBlock className="h-5 w-32" />
<SkeletonBlock className="h-4 w-full" />
<SkeletonBlock className="h-4 w-5/6" />
</div>

<div className="rounded-xl border border-border bg-forge-900 p-6 space-y-4">
<SkeletonBlock className="h-5 w-40" />
<SkeletonBlock className="h-10 w-full rounded-lg" />
</div>
</div>

<div className="space-y-4">
<div className="rounded-xl border border-emerald-border bg-emerald-bg/50 p-5 space-y-2">
<SkeletonBlock className="h-3 w-16" />
<SkeletonBlock className="h-8 w-40" />
</div>
<div className="rounded-xl border border-border bg-forge-900 p-5 space-y-4">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="flex items-center justify-between gap-4">
<SkeletonBlock className="h-3 w-20" />
<SkeletonBlock className="h-4 w-24" />
</div>
))}
</div>
</div>
</div>
</div>
);
}
78 changes: 78 additions & 0 deletions frontend/src/lib/animations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { Variants } from 'framer-motion';

export const fadeIn: Variants = {
initial: { opacity: 0, y: 16 },
animate: {
opacity: 1,
y: 0,
transition: { duration: 0.45, ease: 'easeOut' },
},
};

export const pageTransition: Variants = {
initial: { opacity: 0, y: 12 },
animate: {
opacity: 1,
y: 0,
transition: { duration: 0.35, ease: 'easeOut' },
},
exit: {
opacity: 0,
y: -8,
transition: { duration: 0.2, ease: 'easeIn' },
},
};

export const staggerContainer: Variants = {
initial: {},
animate: {
transition: {
staggerChildren: 0.06,
delayChildren: 0.04,
},
},
};

export const staggerItem: Variants = {
initial: { opacity: 0, y: 14 },
animate: {
opacity: 1,
y: 0,
transition: { duration: 0.35, ease: 'easeOut' },
},
};

export const cardHover: Variants = {
rest: { y: 0, scale: 1 },
hover: {
y: -4,
scale: 1.01,
transition: { duration: 0.18, ease: 'easeOut' },
},
};

export const buttonHover: Variants = {
rest: { scale: 1 },
hover: {
scale: 1.03,
transition: { duration: 0.16, ease: 'easeOut' },
},
tap: {
scale: 0.98,
transition: { duration: 0.08, ease: 'easeOut' },
},
};

export const slideInRight: Variants = {
initial: { opacity: 0, x: 24 },
animate: {
opacity: 1,
x: 0,
transition: { duration: 0.35, ease: 'easeOut' },
},
exit: {
opacity: 0,
x: 16,
transition: { duration: 0.2, ease: 'easeIn' },
},
};
Loading