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
6 changes: 2 additions & 4 deletions frontend/src/components/bounty/BountyCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { GitPullRequest, Clock } from 'lucide-react';
import type { Bounty } from '../../types/bounty';
import { cardHover } from '../../lib/animations';
import { timeLeft, formatCurrency, LANG_COLORS } from '../../lib/utils';
import { CountdownTimer } from './CountdownTimer';

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>
<CountdownTimer deadline={bounty.deadline} showIcon={true} className="ml-1" />
)}
</div>
</div>
Expand Down
5 changes: 2 additions & 3 deletions frontend/src/components/bounty/BountyDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { timeLeft, timeAgo, formatCurrency, LANG_COLORS } from '../../lib/utils'
import { useAuth } from '../../hooks/useAuth';
import { SubmissionForm } from './SubmissionForm';
import { fadeIn } from '../../lib/animations';
import { CountdownTimer } from './CountdownTimer';

interface BountyDetailProps {
bounty: Bounty;
Expand Down Expand Up @@ -138,9 +139,7 @@ export function BountyDetail({ bounty }: BountyDetailProps) {
{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>
<CountdownTimer deadline={bounty.deadline} showIcon={true} />
</div>
)}
<div className="flex items-center justify-between text-sm">
Expand Down
94 changes: 94 additions & 0 deletions frontend/src/components/bounty/CountdownTimer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import React, { useState, useEffect } from 'react';
import { Clock } from 'lucide-react';
import { timeLeftMs, formatTimeLeft } from '../../lib/utils';

interface CountdownTimerProps {
deadline: string | Date;
showIcon?: boolean;
className?: string;
}

type UrgencyLevel = 'normal' | 'warning' | 'urgent' | 'expired';

function getUrgencyLevel(ms: number): UrgencyLevel {
if (ms <= 0) return 'expired';
if (ms < 1 * 60 * 60 * 1000) return 'urgent'; // < 1 hour
if (ms < 24 * 60 * 60 * 1000) return 'warning'; // < 24 hours
return 'normal';
}

const urgencyStyles: Record<UrgencyLevel, string> = {
normal: 'text-text-muted',
warning: 'text-amber-400',
urgent: 'text-red-400',
expired: 'text-text-muted opacity-60',
};

const urgencyBgStyles: Record<UrgencyLevel, string> = {
normal: 'bg-forge-800',
warning: 'bg-amber-400/10 border-amber-400/20',
urgent: 'bg-red-400/10 border-red-400/20 animate-pulse',
expired: 'bg-forge-800 opacity-60',
};

export function CountdownTimer({
deadline,
showIcon = true,
className = '',
}: CountdownTimerProps) {
const [timeLeft, setTimeLeft] = useState(() => timeLeftMs(deadline));

useEffect(() => {
const interval = setInterval(() => {
const remaining = timeLeftMs(deadline);
setTimeLeft(remaining);

// Stop updating when expired
if (remaining <= 0) {
clearInterval(interval);
}
}, 1000);

return () => clearInterval(interval);
}, [deadline]);

const urgency = getUrgencyLevel(timeLeft);
const { days, hours, minutes, seconds } = formatTimeLeft(timeLeft);

if (urgency === 'expired') {
return (
<div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border ${urgencyBgStyles.expired} ${className}`}>
{showIcon && <Clock className="w-3.5 h-3.5" />}
<span className="text-xs font-medium text-text-muted">Expired</span>
</div>
);
}

return (
<div
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border ${urgencyBgStyles[urgency]} ${className}`}
title={`Deadline: ${new Date(deadline).toLocaleString()}`}
>
{showIcon && <Clock className={`w-3.5 h-3.5 ${urgencyStyles[urgency]}`} />}

<div className="flex items-center gap-1">
{days > 0 && (
<span className={`text-xs font-mono font-semibold ${urgencyStyles[urgency]}`}>
{days}d
</span>
)}
<span className={`text-xs font-mono font-semibold ${urgencyStyles[urgency]}`}>
{String(hours).padStart(2, '0')}
</span>
<span className={`text-xs font-mono ${urgencyStyles[urgency]} opacity-60`}>:</span>
<span className={`text-xs font-mono font-semibold ${urgencyStyles[urgency]}`}>
{String(minutes).padStart(2, '0')}
</span>
<span className={`text-xs font-mono ${urgencyStyles[urgency]} opacity-60`}>:</span>
<span className={`text-xs font-mono font-semibold ${urgencyStyles[urgency]}`}>
{String(seconds).padStart(2, '0')}
</span>
</div>
</div>
);
}
116 changes: 116 additions & 0 deletions frontend/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Utility functions for SolFoundry frontend
*/

/**
* Calculate time left until a deadline
* @param deadline - ISO date string or Date object
* @returns Human-readable time left string
*/
export function timeLeft(deadline: string | Date): string {
const now = new Date();
const target = new Date(deadline);
const diff = target.getTime() - now.getTime();

if (diff <= 0) {
return 'Expired';
}

const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));

if (days > 0) {
return `${days}d ${hours}h left`;
}
if (hours > 0) {
return `${hours}h ${minutes}m left`;
}
return `${minutes}m left`;
}

/**
* Calculate time left in milliseconds
* @param deadline - ISO date string or Date object
* @returns Time left in milliseconds (0 if expired)
*/
export function timeLeftMs(deadline: string | Date): number {
const now = new Date();
const target = new Date(deadline);
return Math.max(0, target.getTime() - now.getTime());
}

/**
* Format time left as days, hours, minutes, seconds
* @param ms - Milliseconds
* @returns Object with days, hours, minutes, seconds
*/
export function formatTimeLeft(ms: number): {
days: number;
hours: number;
minutes: number;
seconds: number;
} {
const days = Math.floor(ms / (1000 * 60 * 60 * 24));
const hours = Math.floor((ms % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((ms % (1000 * 60)) / 1000);

return { days, hours, minutes, seconds };
}

/**
* Format a date as relative time (e.g., "2 days ago")
* @param date - ISO date string or Date object
* @returns Relative time string
*/
export function timeAgo(date: string | Date): string {
const now = new Date();
const target = new Date(date);
const diff = now.getTime() - target.getTime();

const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);

if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'just now';
}

/**
* Format currency amount with token symbol
* @param amount - Amount in smallest unit
* @param token - Token symbol
* @returns Formatted currency string
*/
export function formatCurrency(amount: number, token?: string): string {
const symbol = token ?? 'FNDRY';
if (amount >= 1_000_000) {
return `${(amount / 1_000_000).toFixed(1)}M ${symbol}`;
}
if (amount >= 1_000) {
return `${(amount / 1_000).toFixed(1)}K ${symbol}`;
}
return `${amount.toLocaleString()} ${symbol}`;
}

/**
* Language color mapping for skill badges
*/
export const LANG_COLORS: Record<string, string> = {
TypeScript: '#3178c6',
JavaScript: '#f7df1e',
Python: '#3776ab',
Rust: '#dea584',
Go: '#00add8',
Solidity: '#363636',
Move: '#4a90d9',
Cairo: '#f4b53b',
React: '#61dafb',
'Node.js': '#339933',
Docker: '#2496ed',
AWS: '#ff9900',
};