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
68 changes: 68 additions & 0 deletions frontend/src/__tests__/bounty-countdown.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<BountyCountdown deadline={past} variant="compact" />);
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(<BountyCountdown deadline={future} variant="compact" />);
// 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(<BountyCountdown deadline={future} variant="full" />);
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(<BountyCountdown deadline={future} variant="full" />);

// 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(<BountyCountdown deadline={warningDeadline} variant="full" />);
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(<BountyCountdown deadline={urgentDeadline} variant="full" />);
expect(screen.getByText('Urgent!')).toBeTruthy();
});

it('renders nothing meaningful when deadline is null', () => {
render(<BountyCountdown deadline={null} variant="compact" />);
expect(screen.getByText('Expired')).toBeTruthy();
});
});
10 changes: 4 additions & 6 deletions frontend/src/components/bounty/BountyCard.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
Expand Down Expand Up @@ -111,10 +112,7 @@ export function BountyCard({ bounty }: BountyCardProps) {
{bounty.submission_count} PRs
</span>
{bounty.deadline && (
<span className="inline-flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{timeLeft(bounty.deadline)}
</span>
<BountyCountdown deadline={bounty.deadline} variant="compact" />
)}
</div>
</div>
Expand Down
107 changes: 107 additions & 0 deletions frontend/src/components/bounty/BountyCountdown.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span
className={`inline-flex items-center gap-1.5 text-xs font-mono ${colorClasses} px-2 py-0.5 rounded-full border ${className}`}
>
{isExpired ? (
<>Expired</>
) : (
<>
<Clock className={`w-3 h-3 ${iconColor}`} />
{days > 0 && <>{days}d </>}
{String(hours).padStart(2, '0')}:{String(minutes).padStart(2, '0')}
{isUrgent && <AlertTriangle className="w-3 h-3 text-status-error" />}
</>
)}
</span>
);
}

// ── Full variant (segmented boxes, used on detail page) ──────────
return (
<div className={`${className}`}>
{isExpired ? (
<div className={`flex items-center gap-2 font-mono text-sm ${colorClasses} px-3 py-2 rounded-lg border`}>
<Clock className="w-4 h-4" />
<span className="font-semibold">Expired</span>
</div>
) : (
<div className="flex items-center gap-2">
{/* Warning / urgent label */}
{isUrgent && (
<span className="flex items-center gap-1 text-xs font-medium text-status-error mr-1">
<AlertTriangle className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Urgent!</span>
</span>
)}
{!isUrgent && isWarning && (
<span className="flex items-center gap-1 text-xs font-medium text-status-warning mr-1">
<Clock className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Ending soon</span>
</span>
)}

{/* Time segments */}
<div className="flex items-center gap-1.5">
<Segment value={days} label="Days" accent={colorClasses} />
<Separator accent={colorClasses} />
<Segment value={hours} label="Hrs" accent={colorClasses} />
<Separator accent={colorClasses} />
<Segment value={minutes} label="Min" accent={colorClasses} />
<Separator accent={colorClasses} />
<Segment value={seconds} label="Sec" accent={colorClasses} />
</div>
</div>
)}
</div>
);
}

// ── Internal helpers ─────────────────────────────────────────────────

function Segment({ value, label, accent }: { value: number; label: string; accent: string }) {
return (
<div className={`flex flex-col items-center rounded-lg border px-2.5 py-1.5 min-w-[3rem] ${accent}`}>
<span className="font-mono text-lg font-bold leading-none">{String(value).padStart(2, '0')}</span>
<span className="text-[10px] uppercase tracking-wider mt-0.5 opacity-70">{label}</span>
</div>
);
}

function Separator({ accent }: { accent: string }) {
return <span className={`text-lg font-bold ${accent} select-none`}>:</span>;
}

export default BountyCountdown;
19 changes: 10 additions & 9 deletions frontend/src/components/bounty/BountyDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -123,6 +124,14 @@ export function BountyDetail({ bounty }: BountyDetailProps) {
</p>
</div>

{/* Live countdown timer */}
{bounty.deadline && (
<div className="rounded-xl border border-border bg-forge-900 p-5">
<p className="text-xs text-text-muted font-mono mb-3">Time Remaining</p>
<BountyCountdown deadline={bounty.deadline} variant="full" />
</div>
)}

{/* Info card */}
<div className="rounded-xl border border-border bg-forge-900 p-5 space-y-4">
<div className="flex items-center justify-between text-sm">
Expand All @@ -135,14 +144,6 @@ export function BountyDetail({ bounty }: BountyDetailProps) {
<span className="text-text-muted">Tier</span>
<span className="font-mono text-text-primary">{bounty.tier ?? 'T1'}</span>
</div>
{bounty.deadline && (
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Deadline</span>
<span className="font-mono text-status-warning inline-flex items-center gap-1">
<Clock className="w-3.5 h-3.5" /> {timeLeft(bounty.deadline)}
</span>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Submissions</span>
<span className="font-mono text-text-primary inline-flex items-center gap-1">
Expand Down
47 changes: 47 additions & 0 deletions frontend/src/hooks/useCountdown.ts
Original file line number Diff line number Diff line change
@@ -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<CountdownParts>(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;
}
44 changes: 44 additions & 0 deletions frontend/src/lib/animations.ts
Original file line number Diff line number Diff line change
@@ -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 },
};
58 changes: 58 additions & 0 deletions frontend/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Utility functions for the SolFoundry frontend.
*/

/** Language / skill colour map used in bounty cards. */
export const LANG_COLORS: Record<string, string> = {
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}`;
}