diff --git a/frontend/src/components/bounty/BountyCard.tsx b/frontend/src/components/bounty/BountyCard.tsx index aa974a474..eb15f810a 100644 --- a/frontend/src/components/bounty/BountyCard.tsx +++ b/frontend/src/components/bounty/BountyCard.tsx @@ -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 = { @@ -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/BountyDetail.tsx b/frontend/src/components/bounty/BountyDetail.tsx index 65653fa8f..df867b077 100644 --- a/frontend/src/components/bounty/BountyDetail.tsx +++ b/frontend/src/components/bounty/BountyDetail.tsx @@ -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; @@ -138,9 +139,7 @@ export function BountyDetail({ bounty }: BountyDetailProps) { {bounty.deadline && (
Deadline - - {timeLeft(bounty.deadline)} - +
)}
diff --git a/frontend/src/components/bounty/CountdownTimer.tsx b/frontend/src/components/bounty/CountdownTimer.tsx new file mode 100644 index 000000000..6edd27350 --- /dev/null +++ b/frontend/src/components/bounty/CountdownTimer.tsx @@ -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 = { + normal: 'text-text-muted', + warning: 'text-amber-400', + urgent: 'text-red-400', + expired: 'text-text-muted opacity-60', +}; + +const urgencyBgStyles: Record = { + 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 ( +
+ {showIcon && } + Expired +
+ ); + } + + return ( +
+ {showIcon && } + +
+ {days > 0 && ( + + {days}d + + )} + + {String(hours).padStart(2, '0')} + + : + + {String(minutes).padStart(2, '0')} + + : + + {String(seconds).padStart(2, '0')} + +
+
+ ); +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 000000000..ecec125b2 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -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 = { + 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', +};