diff --git a/.gitignore b/.gitignore index 36fca7e4f..9f4c11e48 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,8 @@ downloads/ eggs/ .eggs/ lib/ +!frontend/src/lib/ +!frontend/src/lib/** lib64/ parts/ sdist/ diff --git a/frontend/src/__tests__/loading-skeletons.test.tsx b/frontend/src/__tests__/loading-skeletons.test.tsx new file mode 100644 index 000000000..0fd59404a --- /dev/null +++ b/frontend/src/__tests__/loading-skeletons.test.tsx @@ -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(); + + 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(); + + 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(); + expect(screen.getByRole('status', { name: /loading profile bounties/i })).toBeInTheDocument(); + expect(screen.getAllByTestId('profile-bounty-skeleton')).toHaveLength(4); + + rerender(); + expect(screen.getByRole('status', { name: /loading bounty detail/i })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/bounty/BountyGrid.tsx b/frontend/src/components/bounty/BountyGrid.tsx index 7709ab94c..f671c5f6b 100644 --- a/frontend/src/components/bounty/BountyGrid.tsx +++ b/frontend/src/components/bounty/BountyGrid.tsx @@ -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']; @@ -72,16 +73,7 @@ export function BountyGrid() { {/* Loading state */} {isLoading && ( -
- {Array.from({ length: 6 }).map((_, i) => ( -
-
-
- ))} -
+ )} {/* Error state */} diff --git a/frontend/src/components/home/FeaturedBounties.tsx b/frontend/src/components/home/FeaturedBounties.tsx index 58701e6c2..b927ab705 100644 --- a/frontend/src/components/home/FeaturedBounties.tsx +++ b/frontend/src/components/home/FeaturedBounties.tsx @@ -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' }); @@ -37,11 +38,7 @@ export function FeaturedBounties() { {isLoading && ( -
- {Array.from({ length: 4 }).map((_, i) => ( -
- ))} -
+ )} {isError && ( diff --git a/frontend/src/components/profile/ProfileDashboard.tsx b/frontend/src/components/profile/ProfileDashboard.tsx index 2c509d7aa..814c6bb3a 100644 --- a/frontend/src/components/profile/ProfileDashboard.tsx +++ b/frontend/src/components/profile/ProfileDashboard.tsx @@ -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]; @@ -35,7 +36,7 @@ function BountyStatusBadge({ status }: { status: string }) { function MyBountiesTab({ bounties, loading }: { bounties: Bounty[]; loading: boolean }) { if (loading) { - return
Loading...
; + return ; } if (!bounties.length) { return ( diff --git a/frontend/src/components/ui/Skeletons.tsx b/frontend/src/components/ui/Skeletons.tsx new file mode 100644 index 000000000..fc441bfab --- /dev/null +++ b/frontend/src/components/ui/Skeletons.tsx @@ -0,0 +1,179 @@ +import React from 'react'; + +function SkeletonBlock({ className = '' }: { className?: string }) { + return ( +