Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions frontend/src/components/bounty/BountyCard.tsx
Original file line number Diff line number Diff line change
@@ -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 { BountyCountdownTimer } from './BountyCountdownTimer';

function TierBadge({ tier }: { tier: string }) {
const styles: Record<string, string> = {
Expand Down Expand Up @@ -112,8 +113,7 @@ export function BountyCard({ bounty }: BountyCardProps) {
</span>
{bounty.deadline && (
<span className="inline-flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{timeLeft(bounty.deadline)}
<BountyCountdownTimer deadline={bounty.deadline} compact />
</span>
)}
</div>
Expand Down
97 changes: 97 additions & 0 deletions frontend/src/components/bounty/BountyCountdownTimer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useState, useEffect } from 'react';
import { Clock, AlertTriangle, Zap } from 'lucide-react';

interface BountyCountdownTimerProps {
deadline: string;
compact?: boolean;
}

interface TimeRemaining {
days: number;
hours: number;
minutes: number;
seconds: number;
totalMs: number;
expired: boolean;
}

function calcTimeRemaining(deadline: string): TimeRemaining {
const end = new Date(deadline).getTime();
const now = Date.now();
const totalMs = end - now;

if (totalMs <= 0) {
return { days: 0, hours: 0, minutes: 0, seconds: 0, totalMs: 0, expired: true };
}

const days = Math.floor(totalMs / (1000 * 60 * 60 * 24));
const hours = Math.floor((totalMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((totalMs % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((totalMs % (1000 * 60)) / 1000);

return { days, hours, minutes, seconds, totalMs, expired: false };
}

function formatCompact(t: TimeRemaining): string {
if (t.expired) return 'Expired';
if (t.days > 0) return `${t.days}d ${t.hours}h`;
if (t.hours > 0) return `${t.hours}h ${t.minutes}m`;
if (t.minutes > 0) return `${t.minutes}m ${t.seconds}s`;
return `${t.seconds}s`;
}

function formatFull(t: TimeRemaining): string {
if (t.expired) return 'Expired';
const parts: string[] = [];
if (t.days > 0) parts.push(`${t.days}d`);
if (t.hours > 0) parts.push(`${t.hours}h`);
if (t.minutes > 0) parts.push(`${t.minutes}m`);
parts.push(`${t.seconds}s`);
return parts.join(' ');
}

// Urgency thresholds
const URGENT_MS = 1000 * 60 * 60; // < 1 hour
const WARNING_MS = 1000 * 60 * 60 * 24; // < 24 hours

export function BountyCountdownTimer({ deadline, compact = false }: BountyCountdownTimerProps) {
const [time, setTime] = useState<TimeRemaining>(() => calcTimeRemaining(deadline));

useEffect(() => {
if (time.expired) return;
const id = setInterval(() => {
const next = calcTimeRemaining(deadline);
setTime(next);
if (next.expired) clearInterval(id);
}, 1000);
return () => clearInterval(id);
}, [deadline]);

const isUrgent = !time.expired && time.totalMs < URGENT_MS;
const isWarning = !time.expired && time.totalMs < WARNING_MS && !isUrgent;

// Color scheme
let textColor = 'text-text-muted';
let bgColor = '';
let icon = <Clock className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />;

if (time.expired) {
textColor = 'text-status-error';
icon = <Clock className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />;
} else if (isUrgent) {
textColor = 'text-red-400 font-semibold';
bgColor = 'bg-red-500/10 px-2 py-0.5 rounded';
icon = <Zap className={compact ? 'w-3.5 h-3.5 animate-pulse' : 'w-4 h-4 animate-pulse'} />;
} else if (isWarning) {
textColor = 'text-amber-400';
bgColor = 'bg-amber-500/10 px-2 py-0.5 rounded';
icon = <AlertTriangle className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />;
}

return (
<span className={`inline-flex items-center gap-1.5 ${textColor} ${bgColor} transition-colors duration-300`}>
{icon}
{compact ? formatCompact(time) : formatFull(time)}
</span>
);
}
13 changes: 8 additions & 5 deletions frontend/src/components/bounty/BountyDetail.tsx
Original file line number Diff line number Diff line change
@@ -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 { formatCurrency, LANG_COLORS } from '../../lib/utils';
import { BountyCountdownTimer } from './BountyCountdownTimer';
import { useAuth } from '../../hooks/useAuth';
import { SubmissionForm } from './SubmissionForm';
import { fadeIn } from '../../lib/animations';
Expand Down Expand Up @@ -138,8 +139,8 @@ export function BountyDetail({ bounty }: BountyDetailProps) {
{bounty.deadline && (
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Deadline</span>
<span className="font-mono text-status-warning inline-flex items-center gap-1">
<Clock className="w-3.5 h-3.5" /> {timeLeft(bounty.deadline)}
<span className="font-mono inline-flex items-center gap-1">
<BountyCountdownTimer deadline={bounty.deadline} />
</span>
</div>
)}
Expand All @@ -151,7 +152,9 @@ export function BountyDetail({ bounty }: BountyDetailProps) {
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Posted</span>
<span className="font-mono text-text-muted">{timeAgo(bounty.created_at)}</span>
<span className="font-mono text-text-muted">
{new Date(bounty.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
</div>
</div>
</div>
Expand Down
62 changes: 62 additions & 0 deletions frontend/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
export const LANG_COLORS: Record<string, string> = {
TypeScript: '#3178C6',
JavaScript: '#F7DF1E',
Python: '#3776AB',
Rust: '#DEA584',
Solidity: '#363636',
Go: '#00ADD8',
Java: '#ED8B00',
'C++': '#00599C',
C: '#555555',
Ruby: '#CC342D',
Swift: '#FA7343',
Kotlin: '#7F52FF',
Dart: '#0175C2',
HTML: '#E34F26',
CSS: '#1572B6',
Shell: '#89E051',
YAML: '#CB171E',
JSON: '#292929',
Markdown: '#083FA1',
};

export function formatCurrency(amount: number, token: string): string {
if (token === 'USDC') {
return `$${amount.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
}
return `${amount.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} ${token}`;
}

export function timeAgo(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);

if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
const years = Math.floor(months / 12);
return `${years}y ago`;
}

export function timeLeft(deadline: string): string {
const end = new Date(deadline).getTime();
const now = Date.now();
const diff = end - now;

if (diff <= 0) return 'Expired';

const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));

if (days > 0) return `${days}d ${hours}h left`;
if (hours > 0) return `${hours}h ${minutes}m left`;
return `${minutes}m left`;
}