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 && ( +
+

Time Remaining

+ +
+ )} + {/* 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}`; +}