From 7c860d7ae196993662a1f9aa152e0c77c9a3a33c Mon Sep 17 00:00:00 2001 From: Thanh Nguyen Date: Thu, 28 May 2026 23:08:26 +0700 Subject: [PATCH] feat: add bounty countdown timer --- frontend/src/components/bounty/BountyCard.tsx | 12 ++---- .../src/components/bounty/BountyCountdown.tsx | 39 ++++++++++++++++++ .../src/components/bounty/BountyDetail.tsx | 9 ++-- frontend/src/lib/animations.ts | 28 +++++++++++++ frontend/src/lib/utils.ts | 41 +++++++++++++++++++ 5 files changed, 116 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/bounty/BountyCountdown.tsx create mode 100644 frontend/src/lib/animations.ts 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..3cca70e52 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 { BountyCountdown } from './BountyCountdown'; function TierBadge({ tier }: { tier: string }) { const styles: Record = { @@ -110,12 +111,7 @@ export function BountyCard({ bounty }: BountyCardProps) { {bounty.submission_count} PRs - {bounty.deadline && ( - - - {timeLeft(bounty.deadline)} - - )} + diff --git a/frontend/src/components/bounty/BountyCountdown.tsx b/frontend/src/components/bounty/BountyCountdown.tsx new file mode 100644 index 000000000..a8ba7cae0 --- /dev/null +++ b/frontend/src/components/bounty/BountyCountdown.tsx @@ -0,0 +1,39 @@ +import React, { useEffect, useState } from 'react'; +import { Clock } from 'lucide-react'; +import { deadlineUrgency, timeLeft } from '../../lib/utils'; + +interface BountyCountdownProps { + deadline?: string | null; + className?: string; +} + +export function BountyCountdown({ deadline, className = '' }: BountyCountdownProps) { + const [, setTick] = useState(0); + + useEffect(() => { + if (!deadline) return; + const id = window.setInterval(() => setTick((tick) => tick + 1), 60000); + return () => window.clearInterval(id); + }, [deadline]); + + if (!deadline) return null; + + const urgency = deadlineUrgency(deadline); + const color = { + normal: 'text-text-muted', + warning: 'text-status-warning', + urgent: 'text-status-error', + expired: 'text-text-muted', + }[urgency]; + + return ( + + + {timeLeft(deadline)} + + ); +} diff --git a/frontend/src/components/bounty/BountyDetail.tsx b/frontend/src/components/bounty/BountyDetail.tsx index 65653fa8f..6d1a31a0e 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 { timeAgo, formatCurrency, LANG_COLORS } from '../../lib/utils'; +import { BountyCountdown } from './BountyCountdown'; import { useAuth } from '../../hooks/useAuth'; import { SubmissionForm } from './SubmissionForm'; import { fadeIn } from '../../lib/animations'; @@ -138,9 +139,7 @@ export function BountyDetail({ bounty }: BountyDetailProps) { {bounty.deadline && (
Deadline - - {timeLeft(bounty.deadline)} - +
)}
diff --git a/frontend/src/lib/animations.ts b/frontend/src/lib/animations.ts new file mode 100644 index 000000000..d8ac48113 --- /dev/null +++ b/frontend/src/lib/animations.ts @@ -0,0 +1,28 @@ +export const fadeIn = { + initial: { opacity: 0, y: 8 }, + animate: { opacity: 1, y: 0, transition: { duration: 0.18 } }, +}; + +export const pageTransition = fadeIn; + +export const cardHover = { + rest: { y: 0 }, + hover: { y: -2, transition: { duration: 0.16 } }, +}; + +export const buttonHover = { + whileHover: { scale: 1.02 }, + whileTap: { scale: 0.98 }, +}; + +export const staggerContainer = { + initial: {}, + animate: { transition: { staggerChildren: 0.06 } }, +}; + +export const staggerItem = fadeIn; + +export const slideInRight = { + initial: { opacity: 0, x: 16 }, + animate: { opacity: 1, x: 0, transition: { duration: 0.2 } }, +}; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 000000000..b51ad266c --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,41 @@ +export const LANG_COLORS: Record = { + TypeScript: '#3178c6', JavaScript: '#f7df1e', React: '#61dafb', Rust: '#dea584', Solana: '#14f195', Solidity: '#627eea', Python: '#3776ab', Go: '#00add8', CSS: '#563d7c', HTML: '#e34c26', TS: '#3178c6' +}; + +export function formatCurrency(amount: number, token = 'USDC'): string { + const value = Number(amount ?? 0); + const compact = value >= 1000 ? `${Number((value / 1000).toFixed(1))}k` : `${value}`; + return `${compact} ${token}`; +} + +export function timeAgo(input: string): string { + const diff = Date.now() - new Date(input).getTime(); + if (!Number.isFinite(diff)) return 'unknown'; + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +export function timeLeft(input: string): string { + const diff = new Date(input).getTime() - Date.now(); + if (!Number.isFinite(diff) || diff <= 0) return 'Expired'; + const minutes = Math.floor(diff / 60000) % 60; + const hours = Math.floor(diff / 3600000) % 24; + const days = Math.floor(diff / 86400000); + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} + +export function deadlineUrgency(input?: string | null): 'normal' | 'warning' | 'urgent' | 'expired' { + if (!input) return 'normal'; + const diff = new Date(input).getTime() - Date.now(); + if (!Number.isFinite(diff) || diff <= 0) return 'expired'; + if (diff < 3600000) return 'urgent'; + if (diff < 86400000) return 'warning'; + return 'normal'; +}