diff --git a/frontend/src/__tests__/bounty-countdown.test.tsx b/frontend/src/__tests__/bounty-countdown.test.tsx
new file mode 100644
index 000000000..8821ed08a
--- /dev/null
+++ b/frontend/src/__tests__/bounty-countdown.test.tsx
@@ -0,0 +1,68 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, act } from '@testing-library/react';
+import React from 'react';
+import { BountyCountdown } from '../components/bounty/BountyCountdown';
+
+describe('BountyCountdown', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('renders "Expired" when the deadline has passed', () => {
+ const past = new Date(Date.now() - 60_000).toISOString();
+ render();
+ expect(screen.getByText('Expired')).toBeTruthy();
+ });
+
+ it('renders a compact countdown with days/hours/minutes', () => {
+ const future = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000 + 3 * 60 * 60 * 1000).toISOString();
+ render();
+ // Should show something like "2d 03:xx"
+ const el = screen.getByText(/2d/);
+ expect(el).toBeTruthy();
+ });
+
+ it('renders full variant with segment boxes', () => {
+ const future = new Date(Date.now() + 5 * 60 * 60 * 1000).toISOString();
+ render();
+ expect(screen.getByText('Days')).toBeTruthy();
+ expect(screen.getByText('Hrs')).toBeTruthy();
+ expect(screen.getByText('Min')).toBeTruthy();
+ expect(screen.getByText('Sec')).toBeTruthy();
+ });
+
+ it('updates every second in full variant', () => {
+ const future = new Date(Date.now() + 65_000).toISOString(); // 1m 5s
+ render();
+
+ // Advance 5 seconds
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+
+ // Seconds segment should have changed
+ // (we can't easily assert exact value due to rounding, but the component should re-render)
+ expect(screen.getByText('Min')).toBeTruthy();
+ });
+
+ it('shows warning state when < 24 hours remain', () => {
+ const warningDeadline = new Date(Date.now() + 12 * 60 * 60 * 1000).toISOString();
+ const { container } = render();
+ expect(screen.getByText('Ending soon')).toBeTruthy();
+ });
+
+ it('shows urgent state when < 1 hour remains', () => {
+ const urgentDeadline = new Date(Date.now() + 30 * 60 * 1000).toISOString();
+ const { container } = render();
+ expect(screen.getByText('Urgent!')).toBeTruthy();
+ });
+
+ it('renders nothing meaningful when deadline is null', () => {
+ render();
+ expect(screen.getByText('Expired')).toBeTruthy();
+ });
+});
diff --git a/frontend/src/components/bounty/BountyCard.tsx b/frontend/src/components/bounty/BountyCard.tsx
index aa974a474..0457d8e35 100644
--- a/frontend/src/components/bounty/BountyCard.tsx
+++ b/frontend/src/components/bounty/BountyCard.tsx
@@ -1,10 +1,11 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
-import { GitPullRequest, Clock } from 'lucide-react';
+import { GitPullRequest } from 'lucide-react';
import type { Bounty } from '../../types/bounty';
import { cardHover } from '../../lib/animations';
-import { timeLeft, formatCurrency, LANG_COLORS } from '../../lib/utils';
+import { formatCurrency, LANG_COLORS } from '../../lib/utils';
+import { BountyCountdown } from './BountyCountdown';
function TierBadge({ tier }: { tier: string }) {
const styles: Record = {
@@ -111,10 +112,7 @@ export function BountyCard({ bounty }: BountyCardProps) {
{bounty.submission_count} PRs
{bounty.deadline && (
-
-
- {timeLeft(bounty.deadline)}
-
+
)}
diff --git a/frontend/src/components/bounty/BountyCountdown.tsx b/frontend/src/components/bounty/BountyCountdown.tsx
new file mode 100644
index 000000000..775f2c68f
--- /dev/null
+++ b/frontend/src/components/bounty/BountyCountdown.tsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { Clock, AlertTriangle } from 'lucide-react';
+import { useCountdown } from '../../hooks/useCountdown';
+
+interface BountyCountdownProps {
+ deadline: string | null | undefined;
+ /** 'compact' = single line for cards, 'full' = segmented display for detail page */
+ variant?: 'compact' | 'full';
+ className?: string;
+}
+
+/**
+ * Countdown timer component that shows time remaining until a bounty deadline.
+ *
+ * - Updates every second (real-time)
+ * - Turns amber when < 24 hours remain
+ * - Turns red with pulsing animation when < 1 hour remains
+ * - Shows "Expired" when the deadline has passed
+ * - Responsive: stacks segments on very small screens
+ */
+export function BountyCountdown({ deadline, variant = 'compact', className = '' }: BountyCountdownProps) {
+ const { days, hours, minutes, seconds, isExpired, isUrgent, isWarning } = useCountdown(deadline);
+
+ // ── Color / style logic ──────────────────────────────────────────
+ let colorClasses = 'text-emerald border-emerald-border bg-emerald-bg';
+ if (isWarning) colorClasses = 'text-status-warning border-yellow-500/30 bg-yellow-500/8';
+ if (isUrgent) colorClasses = 'text-status-error border-red-500/30 bg-red-500/8 animate-pulse-glow';
+ if (isExpired) colorClasses = 'text-text-muted border-border bg-forge-800';
+
+ const iconColor = isUrgent ? 'text-status-error' : isWarning ? 'text-status-warning' : 'text-emerald';
+
+ // ── Compact variant (single line, used in BountyCard) ────────────
+ if (variant === 'compact') {
+ return (
+
+ {isExpired ? (
+ <>Expired>
+ ) : (
+ <>
+
+ {days > 0 && <>{days}d >}
+ {String(hours).padStart(2, '0')}:{String(minutes).padStart(2, '0')}
+ {isUrgent && }
+ >
+ )}
+
+ );
+ }
+
+ // ── Full variant (segmented boxes, used on detail page) ──────────
+ return (
+
+ {isExpired ? (
+
+
+ Expired
+
+ ) : (
+
+ {/* Warning / urgent label */}
+ {isUrgent && (
+
+
+ Urgent!
+
+ )}
+ {!isUrgent && isWarning && (
+
+
+ Ending soon
+
+ )}
+
+ {/* Time segments */}
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+// ── Internal helpers ─────────────────────────────────────────────────
+
+function Segment({ value, label, accent }: { value: number; label: string; accent: string }) {
+ return (
+
+ {String(value).padStart(2, '0')}
+ {label}
+
+ );
+}
+
+function Separator({ accent }: { accent: string }) {
+ return :;
+}
+
+export default BountyCountdown;
diff --git a/frontend/src/components/bounty/BountyDetail.tsx b/frontend/src/components/bounty/BountyDetail.tsx
index 65653fa8f..f56df82bd 100644
--- a/frontend/src/components/bounty/BountyDetail.tsx
+++ b/frontend/src/components/bounty/BountyDetail.tsx
@@ -3,10 +3,11 @@ import { Link, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { ArrowLeft, Clock, GitPullRequest, ExternalLink, Loader2, Check, Copy } from 'lucide-react';
import type { Bounty } from '../../types/bounty';
-import { timeLeft, timeAgo, formatCurrency, LANG_COLORS } from '../../lib/utils';
+import { timeAgo, formatCurrency, LANG_COLORS } from '../../lib/utils';
import { useAuth } from '../../hooks/useAuth';
import { SubmissionForm } from './SubmissionForm';
import { fadeIn } from '../../lib/animations';
+import { BountyCountdown } from './BountyCountdown';
interface BountyDetailProps {
bounty: Bounty;
@@ -123,6 +124,14 @@ export function BountyDetail({ bounty }: BountyDetailProps) {
+ {/* Live countdown timer */}
+ {bounty.deadline && (
+
+ )}
+
{/* Info card */}
@@ -135,14 +144,6 @@ export function BountyDetail({ bounty }: BountyDetailProps) {
Tier
{bounty.tier ?? 'T1'}
- {bounty.deadline && (
-
- Deadline
-
- {timeLeft(bounty.deadline)}
-
-
- )}
Submissions
diff --git a/frontend/src/hooks/useCountdown.ts b/frontend/src/hooks/useCountdown.ts
new file mode 100644
index 000000000..cc9883e29
--- /dev/null
+++ b/frontend/src/hooks/useCountdown.ts
@@ -0,0 +1,47 @@
+import { useState, useEffect, useCallback } from 'react';
+
+export interface CountdownParts {
+ days: number;
+ hours: number;
+ minutes: number;
+ seconds: number;
+ totalMs: number;
+ isExpired: boolean;
+ isUrgent: boolean; // < 1 hour
+ isWarning: boolean; // < 24 hours
+}
+
+/**
+ * React hook that returns a live countdown toward `deadline` (ISO string).
+ * Updates every second while the deadline is still in the future.
+ */
+export function useCountdown(deadline: string | null | undefined): CountdownParts {
+ const compute = useCallback((): CountdownParts => {
+ const totalMs = deadline ? new Date(deadline).getTime() - Date.now() : 0;
+ const isExpired = totalMs <= 0;
+ const safeMs = Math.max(totalMs, 0);
+
+ return {
+ days: Math.floor(safeMs / (1000 * 60 * 60 * 24)),
+ hours: Math.floor((safeMs / (1000 * 60 * 60)) % 24),
+ minutes: Math.floor((safeMs / (1000 * 60)) % 60),
+ seconds: Math.floor((safeMs / 1000) % 60),
+ totalMs: safeMs,
+ isExpired,
+ isUrgent: !isExpired && safeMs < 1000 * 60 * 60,
+ isWarning: !isExpired && safeMs < 1000 * 60 * 60 * 24,
+ };
+ }, [deadline]);
+
+ const [parts, setParts] = useState(compute);
+
+ useEffect(() => {
+ // No deadline → return expired state and don't start interval
+ if (!deadline) return;
+
+ const id = setInterval(() => setParts(compute()), 1000);
+ return () => clearInterval(id);
+ }, [deadline, compute]);
+
+ return parts;
+}
diff --git a/frontend/src/lib/animations.ts b/frontend/src/lib/animations.ts
new file mode 100644
index 000000000..ae5f7d2f7
--- /dev/null
+++ b/frontend/src/lib/animations.ts
@@ -0,0 +1,44 @@
+import type { Variants } from 'framer-motion';
+
+export const cardHover: Variants = {
+ rest: { scale: 1, boxShadow: '0 0 0 rgba(0,0,0,0)' },
+ hover: {
+ scale: 1.02,
+ boxShadow: '0 8px 30px rgba(0,230,118,0.08)',
+ transition: { duration: 0.2 },
+ },
+};
+
+export const fadeIn: Variants = {
+ initial: { opacity: 0, y: 12 },
+ animate: { opacity: 1, y: 0, transition: { duration: 0.35 } },
+};
+
+export const pageTransition: Variants = {
+ initial: { opacity: 0, y: 20 },
+ animate: { opacity: 1, y: 0, transition: { duration: 0.4, ease: 'easeOut' } },
+ exit: { opacity: 0, y: -10, transition: { duration: 0.2 } },
+};
+
+export const staggerContainer: Variants = {
+ animate: {
+ transition: {
+ staggerChildren: 0.1,
+ },
+ },
+};
+
+export const staggerItem: Variants = {
+ initial: { opacity: 0, y: 20 },
+ animate: { opacity: 1, y: 0, transition: { duration: 0.4 } },
+};
+
+export const slideInRight: Variants = {
+ initial: { opacity: 0, x: 40 },
+ animate: { opacity: 1, x: 0, transition: { duration: 0.4 } },
+};
+
+export const buttonHover = {
+ whileHover: { scale: 1.05 },
+ whileTap: { scale: 0.95 },
+};
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
new file mode 100644
index 000000000..c1c224e6b
--- /dev/null
+++ b/frontend/src/lib/utils.ts
@@ -0,0 +1,58 @@
+/**
+ * Utility functions for the SolFoundry frontend.
+ */
+
+/** Language / skill colour map used in bounty cards. */
+export const LANG_COLORS: Record = {
+ Rust: '#DEA584',
+ TypeScript: '#3178C6',
+ JavaScript: '#F7DF1E',
+ Python: '#3572A5',
+ Go: '#00ADD8',
+ Solidity: '#AA6746',
+ Move: '#5C4B9E',
+ 'C++': '#F34B7D',
+ Java: '#B07219',
+ Ruby: '#701516',
+ Solana: '#9945FF',
+ Anchor: '#818CF8',
+};
+
+/**
+ * Returns a human-readable string describing how far in the future (or past)
+ * the given ISO date string is.
+ */
+export function timeLeft(deadline: string): string {
+ const diff = new Date(deadline).getTime() - Date.now();
+ if (diff <= 0) return 'Expired';
+
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+ const hours = Math.floor((diff / (1000 * 60 * 60)) % 24);
+ const minutes = Math.floor((diff / (1000 * 60)) % 60);
+
+ if (days > 0) return `${days}d ${hours}h left`;
+ if (hours > 0) return `${hours}h ${minutes}m left`;
+ return `${minutes}m left`;
+}
+
+/**
+ * Returns a human-readable "X ago" string.
+ */
+export function timeAgo(date: string): string {
+ const diff = Date.now() - new Date(date).getTime();
+ const seconds = Math.floor(diff / 1000);
+ if (seconds < 60) return 'just now';
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ return `${days}d ago`;
+}
+
+/**
+ * Format a numeric amount with a token symbol.
+ */
+export function formatCurrency(amount: number, token: string): string {
+ return `${amount.toLocaleString()} ${token}`;
+}