diff --git a/frontend/src/components/bounty/BountyCard.tsx b/frontend/src/components/bounty/BountyCard.tsx index aa974a474..623b96530 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 = { @@ -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/BountyCountdown.tsx b/frontend/src/components/bounty/BountyCountdown.tsx new file mode 100644 index 000000000..04a6c0d3d --- /dev/null +++ b/frontend/src/components/bounty/BountyCountdown.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { motion } from "framer-motion"; + +interface BountyCountdownProps { + /** ISO date string or timestamp when the bounty expires */ + deadline: string | number | Date; + /** Optional label above the countdown */ + label?: string; + /** Optional className for the outer container */ + className?: string; +} + +interface TimeRemaining { + days: number; + hours: number; + minutes: number; + seconds: number; + total: number; +} + +function getTimeRemaining(deadline: Date): TimeRemaining { + const total = deadline.getTime() - Date.now(); + if (total <= 0) { + return { days: 0, hours: 0, minutes: 0, seconds: 0, total: 0 }; + } + return { + days: Math.floor(total / (1000 * 60 * 60 * 24)), + hours: Math.floor((total / (1000 * 60 * 60)) % 24), + minutes: Math.floor((total / (1000 * 60)) % 60), + seconds: Math.floor((total / 1000) % 60), + total, + }; +} + +type Urgency = "expired" | "critical" | "warning" | "safe"; + +function getUrgency(time: TimeRemaining): Urgency { + if (time.total <= 0) return "expired"; + if (time.days < 1) return "critical"; + if (time.days < 3) return "warning"; + return "safe"; +} + +const urgencyStyles: Record = { + safe: { + bg: "bg-emerald-500/10", + text: "text-emerald-400", + ring: "ring-emerald-500/30", + pulse: "", + }, + warning: { + bg: "bg-amber-500/10", + text: "text-amber-400", + ring: "ring-amber-500/30", + pulse: "", + }, + critical: { + bg: "bg-red-500/10", + text: "text-red-400", + ring: "ring-red-500/30", + pulse: "animate-pulse", + }, + expired: { + bg: "bg-gray-500/10", + text: "text-gray-500", + ring: "ring-gray-500/30", + pulse: "", + }, +}; + +function TimeUnit({ value, label, color }: { value: number; label: string; color: string }) { + return ( +
+ + {String(value).padStart(2, "0")} + + + {label} + +
+ ); +} + +export default function BountyCountdown({ + deadline, + label = "Time Remaining", + className = "", +}: BountyCountdownProps) { + const [time, setTime] = useState(() => + getTimeRemaining(new Date(deadline)) + ); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + const deadlineDate = new Date(deadline); + const interval = setInterval(() => { + setTime(getTimeRemaining(deadlineDate)); + }, 1000); + return () => clearInterval(interval); + }, [deadline]); + + const urgency = getUrgency(time); + const styles = urgencyStyles[urgency]; + + if (!mounted) { + return ( +
+

{label}

+
+ {["Days", "Hrs", "Min", "Sec"].map((l) => ( +
+ + -- + + + {l} + +
+ ))} +
+
+ ); + } + + return ( + +

+ {label} +

+ + {urgency === "expired" ? ( +

Expired

+ ) : ( +
+ + : + + : + + : + +
+ )} +
+ ); +}