From 67dd2e94dda06eb590e20d5e8b4306e011f7166f Mon Sep 17 00:00:00 2001 From: CHY9213 Date: Wed, 27 May 2026 19:40:48 +0800 Subject: [PATCH] feat: add real-time CountdownTimer component with urgency colors --- frontend/src/components/bounty/BountyCard.tsx | 10 +- .../src/components/bounty/BountyDetail.tsx | 9 +- .../src/components/bounty/CountdownTimer.tsx | 119 ++++++++++++++++++ 3 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/bounty/CountdownTimer.tsx diff --git a/frontend/src/components/bounty/BountyCard.tsx b/frontend/src/components/bounty/BountyCard.tsx index aa974a474..072b5e99a 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 { 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..2c855ed96 100644 --- a/frontend/src/components/bounty/BountyDetail.tsx +++ b/frontend/src/components/bounty/BountyDetail.tsx @@ -1,11 +1,12 @@ 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 { timeAgo, formatCurrency, LANG_COLORS } from '../../lib/utils'; import { useAuth } from '../../hooks/useAuth'; import { SubmissionForm } from './SubmissionForm'; +import { CountdownTimer } from './CountdownTimer'; import { fadeIn } from '../../lib/animations'; interface BountyDetailProps { @@ -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..6a3623c72 --- /dev/null +++ b/frontend/src/components/bounty/CountdownTimer.tsx @@ -0,0 +1,119 @@ +import React, { useState, useEffect } from 'react'; +import { Clock, AlertTriangle, Timer, Ban } from 'lucide-react'; + +interface CountdownTimerProps { + deadline: string; + /** @default 'minimal' */ + variant?: 'minimal' | 'full'; + /** @default false */ + showIcon?: boolean; +} + +interface TimeLeft { + days: number; + hours: number; + minutes: number; + seconds: number; +} + +function calcTimeLeft(deadline: string): TimeLeft | null { + const diff = new Date(deadline).getTime() - Date.now(); + if (diff <= 0) return null; + return { + days: Math.floor(diff / 86_400_000), + hours: Math.floor((diff % 86_400_000) / 3_600_000), + minutes: Math.floor((diff % 3_600_000) / 60_000), + seconds: Math.floor((diff % 60_000) / 1_000), + }; +} + +function pad(n: number): string { + return String(n).padStart(2, '0'); +} + +export function CountdownTimer({ deadline, variant = 'minimal', showIcon = true }: CountdownTimerProps) { + const [timeLeft, setTimeLeft] = useState(() => calcTimeLeft(deadline)); + + useEffect(() => { + setTimeLeft(calcTimeLeft(deadline)); + const interval = setInterval(() => { + setTimeLeft(calcTimeLeft(deadline)); + }, 1_000); + return () => clearInterval(interval); + }, [deadline]); + + // If deadline is set but we can't parse it, show nothing + if (!deadline) return null; + + // Expired + if (timeLeft === null) { + const expired = new Date(deadline).getTime() < Date.now(); + if (expired) { + return ( + + {showIcon && } + Expired + + ); + } + return ( + + {showIcon && } + No deadline + + ); + } + + const totalHours = timeLeft.days * 24 + timeLeft.hours; + const isUrgent = totalHours < 1; + const isWarning = totalHours < 24 && !isUrgent; + + const colorClass = isUrgent + ? 'text-status-error' + : isWarning + ? 'text-status-warning' + : 'text-text-muted'; + + const bgClass = isUrgent + ? 'bg-status-error/10' + : isWarning + ? 'bg-status-warning/10' + : 'bg-transparent'; + + if (variant === 'full') { + return ( +
+ {showIcon && (isUrgent ? : )} +
+ {timeLeft.days > 0 && ( + <> + {timeLeft.days} + d + + )} + {pad(timeLeft.hours)} + h + {pad(timeLeft.minutes)} + m + {pad(timeLeft.seconds)} + s +
+ {isUrgent && Urgent!} +
+ ); + } + + // Minimal variant + const label = timeLeft.days > 0 + ? `${timeLeft.days}d ${timeLeft.hours}h left` + : isUrgent + ? `${timeLeft.minutes}m ${timeLeft.seconds}s left` + : `${timeLeft.hours}h ${timeLeft.minutes}m left`; + + return ( + + {showIcon && (isUrgent ? : )} + {label} + + ); +}