Skip to content

Commit 0b4d0a4

Browse files
author
OpenClaw Bot
committed
feat: bounty countdown timer - Bounty #826 (100K $FNDRY)
1 parent 0bb39b1 commit 0b4d0a4

File tree

1 file changed

+167
-0
lines changed

1 file changed

+167
-0
lines changed
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { motion, AnimatePresence } from "framer-motion";
5+
6+
interface BountyCountdownProps {
7+
/** ISO date string or timestamp when the bounty expires */
8+
deadline: string | number | Date;
9+
/** Optional label above the countdown */
10+
label?: string;
11+
/** Optional className for the outer container */
12+
className?: string;
13+
}
14+
15+
interface TimeRemaining {
16+
days: number;
17+
hours: number;
18+
minutes: number;
19+
seconds: number;
20+
total: number;
21+
}
22+
23+
function getTimeRemaining(deadline: Date): TimeRemaining {
24+
const total = deadline.getTime() - Date.now();
25+
if (total <= 0) {
26+
return { days: 0, hours: 0, minutes: 0, seconds: 0, total: 0 };
27+
}
28+
return {
29+
days: Math.floor(total / (1000 * 60 * 60 * 24)),
30+
hours: Math.floor((total / (1000 * 60 * 60)) % 24),
31+
minutes: Math.floor((total / (1000 * 60)) % 60),
32+
seconds: Math.floor((total / 1000) % 60),
33+
total,
34+
};
35+
}
36+
37+
type Urgency = "expired" | "critical" | "warning" | "safe";
38+
39+
function getUrgency(time: TimeRemaining): Urgency {
40+
if (time.total <= 0) return "expired";
41+
if (time.days < 1) return "critical";
42+
if (time.days < 3) return "warning";
43+
return "safe";
44+
}
45+
46+
const urgencyStyles: Record<Urgency, { bg: string; text: string; ring: string; label: string }> = {
47+
safe: {
48+
bg: "bg-emerald-500/10",
49+
text: "text-emerald-400",
50+
ring: "ring-emerald-500/30",
51+
label: "Plenty of time",
52+
},
53+
warning: {
54+
bg: "bg-amber-500/10",
55+
text: "text-amber-400",
56+
ring: "ring-amber-500/30",
57+
label: "Expiring soon",
58+
},
59+
critical: {
60+
bg: "bg-red-500/10",
61+
text: "text-red-400",
62+
ring: "ring-red-500/30",
63+
label: "Almost expired!",
64+
},
65+
expired: {
66+
bg: "bg-gray-500/10",
67+
text: "text-gray-400",
68+
ring: "ring-gray-500/30",
69+
label: "Expired",
70+
},
71+
};
72+
73+
function TimeUnit({ value, label, color }: { value: number; label: string; color: string }) {
74+
return (
75+
<div className="flex flex-col items-center">
76+
<motion.div
77+
key={value}
78+
initial={{ y: -8, opacity: 0 }}
79+
animate={{ y: 0, opacity: 1 }}
80+
transition={{ type: "spring", stiffness: 300, damping: 20 }}
81+
className={`text-xl sm:text-2xl md:text-3xl font-bold font-mono tabular-nums ${color}`}
82+
>
83+
{String(value).padStart(2, "0")}
84+
</motion.div>
85+
<span className="text-[10px] sm:text-xs uppercase tracking-wider text-gray-500 mt-0.5">
86+
{label}
87+
</span>
88+
</div>
89+
);
90+
}
91+
92+
export default function BountyCountdown({
93+
deadline,
94+
label = "Time Remaining",
95+
className = "",
96+
}: BountyCountdownProps) {
97+
const [time, setTime] = useState<TimeRemaining>(() =>
98+
getTimeRemaining(new Date(deadline))
99+
);
100+
const [mounted, setMounted] = useState(false);
101+
102+
useEffect(() => {
103+
setMounted(true);
104+
const interval = setInterval(() => {
105+
setTime(getTimeRemaining(new Date(deadline)));
106+
}, 1000);
107+
return () => clearInterval(interval);
108+
}, [deadline]);
109+
110+
const urgency = getUrgency(time);
111+
const styles = urgencyStyles[urgency];
112+
113+
// Avoid hydration mismatch
114+
if (!mounted) {
115+
return (
116+
<div className={`rounded-lg p-3 ring-1 ${styles.ring} ${styles.bg} ${className}`}>
117+
<p className={`text-xs font-medium ${styles.text} mb-2`}>{label}</p>
118+
<div className="flex items-center justify-center gap-3 sm:gap-4">
119+
{["Days", "Hrs", "Min", "Sec"].map((l) => (
120+
<div key={l} className="flex flex-col items-center">
121+
<span className={`text-xl sm:text-2xl md:text-3xl font-bold font-mono ${styles.text}`}>
122+
--
123+
</span>
124+
<span className="text-[10px] sm:text-xs uppercase tracking-wider text-gray-500 mt-0.5">
125+
{l}
126+
</span>
127+
</div>
128+
))}
129+
</div>
130+
</div>
131+
);
132+
}
133+
134+
return (
135+
<motion.div
136+
initial={{ opacity: 0, scale: 0.95 }}
137+
animate={{ opacity: 1, scale: 1 }}
138+
transition={{ duration: 0.3 }}
139+
className={`rounded-lg p-3 ring-1 ${styles.ring} ${styles.bg} ${className}`}
140+
>
141+
<div className="flex items-center justify-between mb-2">
142+
<p className={`text-xs font-medium ${styles.text}`}>{label}</p>
143+
<AnimatePresence mode="wait">
144+
<motion.span
145+
key={urgency}
146+
initial={{ opacity: 0, x: 6 }}
147+
animate={{ opacity: 1, x: 0 }}
148+
exit={{ opacity: 0, x: -6 }}
149+
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${styles.bg} ${styles.text}`}
150+
>
151+
{styles.label}
152+
</motion.span>
153+
</AnimatePresence>
154+
</div>
155+
156+
<div className="flex items-center justify-center gap-3 sm:gap-4">
157+
<TimeUnit value={time.days} label="Days" color={styles.text} />
158+
<span className={`text-lg font-light ${styles.text} opacity-40 -mt-4`}>:</span>
159+
<TimeUnit value={time.hours} label="Hrs" color={styles.text} />
160+
<span className={`text-lg font-light ${styles.text} opacity-40 -mt-4`}>:</span>
161+
<TimeUnit value={time.minutes} label="Min" color={styles.text} />
162+
<span className={`text-lg font-light ${styles.text} opacity-40 -mt-4`}>:</span>
163+
<TimeUnit value={time.seconds} label="Sec" color={styles.text} />
164+
</div>
165+
</motion.div>
166+
);
167+
}

0 commit comments

Comments
 (0)