From defea315aaf053be4d2ee5bda1e7ec69f707ebca Mon Sep 17 00:00:00 2001 From: a918124259a Date: Thu, 28 May 2026 23:21:43 +0800 Subject: [PATCH] fix: add lib/utils.ts with shared utility functions --- frontend/src/components/bounty/BountyCard.tsx | 8 +- .../bounty/BountyCountdownTimer.tsx | 97 +++++++++++++++++++ .../src/components/bounty/BountyDetail.tsx | 13 ++- frontend/src/lib/utils.ts | 62 ++++++++++++ 4 files changed, 171 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/bounty/BountyCountdownTimer.tsx create mode 100644 frontend/src/lib/utils.ts diff --git a/frontend/src/components/bounty/BountyCard.tsx b/frontend/src/components/bounty/BountyCard.tsx index aa974a474..e14251103 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 { BountyCountdownTimer } from './BountyCountdownTimer'; function TierBadge({ tier }: { tier: string }) { const styles: Record = { @@ -112,8 +113,7 @@ export function BountyCard({ bounty }: BountyCardProps) { {bounty.deadline && ( - - {timeLeft(bounty.deadline)} + )} diff --git a/frontend/src/components/bounty/BountyCountdownTimer.tsx b/frontend/src/components/bounty/BountyCountdownTimer.tsx new file mode 100644 index 000000000..379e1f5a7 --- /dev/null +++ b/frontend/src/components/bounty/BountyCountdownTimer.tsx @@ -0,0 +1,97 @@ +import { useState, useEffect } from 'react'; +import { Clock, AlertTriangle, Zap } from 'lucide-react'; + +interface BountyCountdownTimerProps { + deadline: string; + compact?: boolean; +} + +interface TimeRemaining { + days: number; + hours: number; + minutes: number; + seconds: number; + totalMs: number; + expired: boolean; +} + +function calcTimeRemaining(deadline: string): TimeRemaining { + const end = new Date(deadline).getTime(); + const now = Date.now(); + const totalMs = end - now; + + if (totalMs <= 0) { + return { days: 0, hours: 0, minutes: 0, seconds: 0, totalMs: 0, expired: true }; + } + + const days = Math.floor(totalMs / (1000 * 60 * 60 * 24)); + const hours = Math.floor((totalMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((totalMs % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((totalMs % (1000 * 60)) / 1000); + + return { days, hours, minutes, seconds, totalMs, expired: false }; +} + +function formatCompact(t: TimeRemaining): string { + if (t.expired) return 'Expired'; + if (t.days > 0) return `${t.days}d ${t.hours}h`; + if (t.hours > 0) return `${t.hours}h ${t.minutes}m`; + if (t.minutes > 0) return `${t.minutes}m ${t.seconds}s`; + return `${t.seconds}s`; +} + +function formatFull(t: TimeRemaining): string { + if (t.expired) return 'Expired'; + const parts: string[] = []; + if (t.days > 0) parts.push(`${t.days}d`); + if (t.hours > 0) parts.push(`${t.hours}h`); + if (t.minutes > 0) parts.push(`${t.minutes}m`); + parts.push(`${t.seconds}s`); + return parts.join(' '); +} + +// Urgency thresholds +const URGENT_MS = 1000 * 60 * 60; // < 1 hour +const WARNING_MS = 1000 * 60 * 60 * 24; // < 24 hours + +export function BountyCountdownTimer({ deadline, compact = false }: BountyCountdownTimerProps) { + const [time, setTime] = useState(() => calcTimeRemaining(deadline)); + + useEffect(() => { + if (time.expired) return; + const id = setInterval(() => { + const next = calcTimeRemaining(deadline); + setTime(next); + if (next.expired) clearInterval(id); + }, 1000); + return () => clearInterval(id); + }, [deadline]); + + const isUrgent = !time.expired && time.totalMs < URGENT_MS; + const isWarning = !time.expired && time.totalMs < WARNING_MS && !isUrgent; + + // Color scheme + let textColor = 'text-text-muted'; + let bgColor = ''; + let icon = ; + + if (time.expired) { + textColor = 'text-status-error'; + icon = ; + } else if (isUrgent) { + textColor = 'text-red-400 font-semibold'; + bgColor = 'bg-red-500/10 px-2 py-0.5 rounded'; + icon = ; + } else if (isWarning) { + textColor = 'text-amber-400'; + bgColor = 'bg-amber-500/10 px-2 py-0.5 rounded'; + icon = ; + } + + return ( + + {icon} + {compact ? formatCompact(time) : formatFull(time)} + + ); +} diff --git a/frontend/src/components/bounty/BountyDetail.tsx b/frontend/src/components/bounty/BountyDetail.tsx index 65653fa8f..d90aa8e2d 100644 --- a/frontend/src/components/bounty/BountyDetail.tsx +++ b/frontend/src/components/bounty/BountyDetail.tsx @@ -1,9 +1,10 @@ import React, { useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { motion } from 'framer-motion'; -import { ArrowLeft, Clock, GitPullRequest, ExternalLink, Loader2, Check, Copy } from 'lucide-react'; +import { ArrowLeft, GitPullRequest, ExternalLink, Loader2, Check, Copy } from 'lucide-react'; import type { Bounty } from '../../types/bounty'; -import { timeLeft, timeAgo, formatCurrency, LANG_COLORS } from '../../lib/utils'; +import { formatCurrency, LANG_COLORS } from '../../lib/utils'; +import { BountyCountdownTimer } from './BountyCountdownTimer'; import { useAuth } from '../../hooks/useAuth'; import { SubmissionForm } from './SubmissionForm'; import { fadeIn } from '../../lib/animations'; @@ -138,8 +139,8 @@ export function BountyDetail({ bounty }: BountyDetailProps) { {bounty.deadline && (
Deadline - - {timeLeft(bounty.deadline)} + +
)} @@ -151,7 +152,9 @@ export function BountyDetail({ bounty }: BountyDetailProps) {
Posted - {timeAgo(bounty.created_at)} + + {new Date(bounty.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} +
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 000000000..da9c41f13 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,62 @@ +export const LANG_COLORS: Record = { + TypeScript: '#3178C6', + JavaScript: '#F7DF1E', + Python: '#3776AB', + Rust: '#DEA584', + Solidity: '#363636', + Go: '#00ADD8', + Java: '#ED8B00', + 'C++': '#00599C', + C: '#555555', + Ruby: '#CC342D', + Swift: '#FA7343', + Kotlin: '#7F52FF', + Dart: '#0175C2', + HTML: '#E34F26', + CSS: '#1572B6', + Shell: '#89E051', + YAML: '#CB171E', + JSON: '#292929', + Markdown: '#083FA1', +}; + +export function formatCurrency(amount: number, token: string): string { + if (token === 'USDC') { + return `$${amount.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`; + } + return `${amount.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} ${token}`; +} + +export function timeAgo(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const seconds = Math.floor((now.getTime() - date.getTime()) / 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); + if (days < 30) return `${days}d ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + const years = Math.floor(months / 12); + return `${years}y ago`; +} + +export function timeLeft(deadline: string): string { + const end = new Date(deadline).getTime(); + const now = Date.now(); + const diff = end - now; + + 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`; +}