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 (
+
+ );
+}
+
+export function BountyCardSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+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 (
+
+ {Array.from({ length: count }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
+export function LeaderboardSkeleton() {
+ return (
+
+
+ {[0, 1, 2].map((index) => (
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ {Array.from({ length: 5 }).map((_, index) => (
+
+ ))}
+
+
+ );
+}
+
+export function ProfileBountyListSkeleton() {
+ return (
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
+export function BountyDetailSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 5 }).map((_, index) => (
+
+
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/frontend/src/lib/animations.ts b/frontend/src/lib/animations.ts
new file mode 100644
index 000000000..a155cc4d4
--- /dev/null
+++ b/frontend/src/lib/animations.ts
@@ -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' },
+ },
+};
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
new file mode 100644
index 000000000..ccf2480ad
--- /dev/null
+++ b/frontend/src/lib/utils.ts
@@ -0,0 +1,67 @@
+const RELATIVE_TIME_UNITS = [
+ { label: 'year', seconds: 365 * 24 * 60 * 60 },
+ { label: 'month', seconds: 30 * 24 * 60 * 60 },
+ { label: 'week', seconds: 7 * 24 * 60 * 60 },
+ { label: 'day', seconds: 24 * 60 * 60 },
+ { label: 'hour', seconds: 60 * 60 },
+ { label: 'minute', seconds: 60 },
+] as const;
+
+export const LANG_COLORS: Record = {
+ TypeScript: '#3178c6',
+ JavaScript: '#f7df1e',
+ Rust: '#dea584',
+ Solidity: '#627eea',
+ Python: '#3776ab',
+ Go: '#00add8',
+ React: '#61dafb',
+ Node: '#68a063',
+ Other: '#5c5c78',
+};
+
+export function formatCurrency(amount?: number | null, token = 'USDC'): string {
+ const value = Number(amount ?? 0);
+ const formatted = new Intl.NumberFormat('en-US', {
+ maximumFractionDigits: value >= 100 ? 0 : 2,
+ }).format(value);
+
+ return `${formatted} ${token}`;
+}
+
+export function timeAgo(timestamp?: string | null): string {
+ if (!timestamp) return 'unknown';
+
+ const date = new Date(timestamp);
+ if (Number.isNaN(date.getTime())) return 'unknown';
+
+ const deltaSeconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000));
+ if (deltaSeconds < 30) return 'just now';
+
+ for (const unit of RELATIVE_TIME_UNITS) {
+ const value = Math.floor(deltaSeconds / unit.seconds);
+ if (value >= 1) {
+ return `${value} ${unit.label}${value === 1 ? '' : 's'} ago`;
+ }
+ }
+
+ return 'just now';
+}
+
+export function timeLeft(deadline?: string | null): string {
+ if (!deadline) return 'No deadline';
+
+ const date = new Date(deadline);
+ if (Number.isNaN(date.getTime())) return 'Invalid deadline';
+
+ const deltaMs = date.getTime() - Date.now();
+ if (deltaMs <= 0) return 'Expired';
+
+ const totalMinutes = Math.floor(deltaMs / 60000);
+ const days = Math.floor(totalMinutes / (24 * 60));
+ const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
+ const minutes = totalMinutes % 60;
+
+ if (days > 0) return `${days}d ${hours}h left`;
+ if (hours > 0) return `${hours}h ${minutes}m left`;
+ return `${Math.max(minutes, 1)}m left`;
+}
diff --git a/frontend/src/pages/BountyDetailPage.tsx b/frontend/src/pages/BountyDetailPage.tsx
index f147509c0..f4aab60ed 100644
--- a/frontend/src/pages/BountyDetailPage.tsx
+++ b/frontend/src/pages/BountyDetailPage.tsx
@@ -5,6 +5,7 @@ import { BountyDetail } from '../components/bounty/BountyDetail';
import { useBounty } from '../hooks/useBounties';
import { fadeIn } from '../lib/animations';
import { motion } from 'framer-motion';
+import { BountyDetailSkeleton } from '../components/ui/Skeletons';
export function BountyDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -13,13 +14,7 @@ export function BountyDetailPage() {
return (
{isLoading && (
-
- {[1, 2, 3].map((i) => (
-
- ))}
-
+
)}
{isError && !isLoading && (
diff --git a/frontend/src/pages/LeaderboardPage.tsx b/frontend/src/pages/LeaderboardPage.tsx
index f79caff97..315a1372c 100644
--- a/frontend/src/pages/LeaderboardPage.tsx
+++ b/frontend/src/pages/LeaderboardPage.tsx
@@ -6,6 +6,7 @@ import { LeaderboardTable } from '../components/leaderboard/LeaderboardTable';
import { useLeaderboard } from '../hooks/useLeaderboard';
import type { TimePeriod } from '../types/leaderboard';
import { fadeIn } from '../lib/animations';
+import { LeaderboardSkeleton } from '../components/ui/Skeletons';
const PERIODS: { label: string; value: TimePeriod }[] = [
{ label: '7d', value: '7d' },
@@ -47,9 +48,7 @@ export function LeaderboardPage() {
{/* Loading */}
{isLoading && (
-
+
)}
{/* Error */}