Skip to content

Commit dc792b9

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

File tree

2 files changed

+164
-6
lines changed

2 files changed

+164
-6
lines changed

frontend/src/components/bounty/BountyCard.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import React from 'react';
22
import { useNavigate } from 'react-router-dom';
33
import { motion } from 'framer-motion';
4-
import { GitPullRequest, Clock } from 'lucide-react';
4+
import { GitPullRequest } from 'lucide-react';
55
import type { Bounty } from '../../types/bounty';
66
import { cardHover } from '../../lib/animations';
7-
import { timeLeft, formatCurrency, LANG_COLORS } from '../../lib/utils';
7+
import { formatCurrency, LANG_COLORS } from '../../lib/utils';
8+
import BountyCountdown from './BountyCountdown';
89

910
function TierBadge({ tier }: { tier: string }) {
1011
const styles: Record<string, string> = {
@@ -111,10 +112,7 @@ export function BountyCard({ bounty }: BountyCardProps) {
111112
{bounty.submission_count} PRs
112113
</span>
113114
{bounty.deadline && (
114-
<span className="inline-flex items-center gap-1">
115-
<Clock className="w-3.5 h-3.5" />
116-
{timeLeft(bounty.deadline)}
117-
</span>
115+
<BountyCountdown deadline={bounty.deadline} className="ml-2" />
118116
)}
119117
</div>
120118
</div>
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { motion } 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; pulse: string }> = {
47+
safe: {
48+
bg: "bg-emerald-500/10",
49+
text: "text-emerald-400",
50+
ring: "ring-emerald-500/30",
51+
pulse: "",
52+
},
53+
warning: {
54+
bg: "bg-amber-500/10",
55+
text: "text-amber-400",
56+
ring: "ring-amber-500/30",
57+
pulse: "",
58+
},
59+
critical: {
60+
bg: "bg-red-500/10",
61+
text: "text-red-400",
62+
ring: "ring-red-500/30",
63+
pulse: "animate-pulse",
64+
},
65+
expired: {
66+
bg: "bg-gray-500/10",
67+
text: "text-gray-500",
68+
ring: "ring-gray-500/30",
69+
pulse: "",
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 min-w-[2rem]">
76+
<motion.span
77+
key={value}
78+
initial={{ y: -6, opacity: 0 }}
79+
animate={{ y: 0, opacity: 1 }}
80+
transition={{ type: "spring", stiffness: 300, damping: 25 }}
81+
className={`text-lg sm:text-xl md:text-2xl font-bold font-mono tabular-nums ${color}`}
82+
>
83+
{String(value).padStart(2, "0")}
84+
</motion.span>
85+
<span className="text-[9px] sm:text-[10px] 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 deadlineDate = new Date(deadline);
105+
const interval = setInterval(() => {
106+
setTime(getTimeRemaining(deadlineDate));
107+
}, 1000);
108+
return () => clearInterval(interval);
109+
}, [deadline]);
110+
111+
const urgency = getUrgency(time);
112+
const styles = urgencyStyles[urgency];
113+
114+
if (!mounted) {
115+
return (
116+
<div className={`rounded-lg p-2.5 ring-1 ${styles.ring} ${styles.bg} ${className}`}>
117+
<p className={`text-[10px] font-medium ${styles.text} mb-1.5`}>{label}</p>
118+
<div className="flex items-center justify-center gap-2">
119+
{["Days", "Hrs", "Min", "Sec"].map((l) => (
120+
<div key={l} className="flex flex-col items-center min-w-[2rem]">
121+
<span className={`text-lg sm:text-xl md:text-2xl font-bold font-mono ${styles.text}`}>
122+
--
123+
</span>
124+
<span className="text-[9px] sm:text-[10px] 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.97 }}
137+
animate={{ opacity: 1, scale: 1 }}
138+
transition={{ duration: 0.25 }}
139+
className={`rounded-lg p-2.5 ring-1 ${styles.ring} ${styles.bg} ${className}`}
140+
>
141+
<p className={`text-[10px] font-medium ${styles.text} mb-1.5 ${urgency === "critical" ? styles.pulse : ""}`}>
142+
{label}
143+
</p>
144+
145+
{urgency === "expired" ? (
146+
<p className={`text-sm font-semibold text-center ${styles.text}`}>Expired</p>
147+
) : (
148+
<div className="flex items-center justify-center gap-2">
149+
<TimeUnit value={time.days} label="Days" color={styles.text} />
150+
<span className={`text-base font-light ${styles.text} opacity-30 -mt-3`}>:</span>
151+
<TimeUnit value={time.hours} label="Hrs" color={styles.text} />
152+
<span className={`text-base font-light ${styles.text} opacity-30 -mt-3`}>:</span>
153+
<TimeUnit value={time.minutes} label="Min" color={styles.text} />
154+
<span className={`text-base font-light ${styles.text} opacity-30 -mt-3`}>:</span>
155+
<TimeUnit value={time.seconds} label="Sec" color={styles.text} />
156+
</div>
157+
)}
158+
</motion.div>
159+
);
160+
}

0 commit comments

Comments
 (0)