diff --git a/frontend/src/components/bounty/BountyCard.tsx b/frontend/src/components/bounty/BountyCard.tsx index aa974a474..9a52e8a94 100644 --- a/frontend/src/components/bounty/BountyCard.tsx +++ b/frontend/src/components/bounty/BountyCard.tsx @@ -1,129 +1,130 @@ -import React from 'react'; -import { useNavigate } from 'react-router-dom'; -import { motion } from 'framer-motion'; -import { GitPullRequest, Clock } from 'lucide-react'; -import type { Bounty } from '../../types/bounty'; -import { cardHover } from '../../lib/animations'; -import { timeLeft, formatCurrency, LANG_COLORS } from '../../lib/utils'; - -function TierBadge({ tier }: { tier: string }) { - const styles: Record = { - T1: 'bg-tier-t1/10 text-tier-t1 border border-tier-t1/20', - T2: 'bg-tier-t2/10 text-tier-t2 border border-tier-t2/20', - T3: 'bg-tier-t3/10 text-tier-t3 border border-tier-t3/20', - }; - return ( - - {tier} - - ); -} - -interface BountyCardProps { - bounty: Bounty; -} - -export function BountyCard({ bounty }: BountyCardProps) { - const navigate = useNavigate(); - - const orgName = bounty.org_name ?? bounty.github_issue_url?.split('/')[3] ?? 'unknown'; - const repoName = bounty.repo_name ?? bounty.github_issue_url?.split('/')[4] ?? 'repo'; - const issueNumber = bounty.issue_number ?? bounty.github_issue_url?.split('/').pop(); - const skills = bounty.skills?.slice(0, 3) ?? []; - - const statusLabel = { - open: 'Open', - in_review: 'In Review', - funded: 'Funded', - completed: 'Completed', - cancelled: 'Cancelled', - }[bounty.status] ?? 'Open'; - - const statusColor = { - open: 'text-emerald', - in_review: 'text-magenta', - funded: 'text-status-info', - completed: 'text-text-muted', - cancelled: 'text-status-error', - }[bounty.status] ?? 'text-emerald'; - - const dotColor = { - open: 'bg-emerald', - in_review: 'bg-magenta', - funded: 'bg-status-info', - completed: 'bg-text-muted', - cancelled: 'bg-status-error', - }[bounty.status] ?? 'bg-emerald'; - - return ( - navigate(`/bounties/${bounty.id}`)} - className="relative rounded-xl border border-border bg-forge-900 p-5 cursor-pointer transition-colors duration-200 overflow-hidden group" - > - {/* Row 1: Repo + Tier */} -
-
- {bounty.org_avatar_url && ( - - )} - - {orgName}/{repoName} - {issueNumber && #{issueNumber}} - -
- -
- - {/* Row 2: Title */} -

- {bounty.title} -

- - {/* Row 3: Language dots */} - {skills.length > 0 && ( -
- {skills.map((lang) => ( - - - {lang} - - ))} -
- )} - - {/* Separator */} -
- - {/* Row 4: Reward + Meta */} -
- - {formatCurrency(bounty.reward_amount, bounty.reward_token)} - -
- - - {bounty.submission_count} PRs - - {bounty.deadline && ( - - - {timeLeft(bounty.deadline)} - - )} -
-
- - {/* Status badge */} - - - {statusLabel} - - - ); -} +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { GitPullRequest, Clock } from 'lucide-react'; +import type { Bounty } from '../../types/bounty'; +import { cardHover } from '../../lib/animations'; +import { formatCurrency, LANG_COLORS } from '../../lib/utils'; +import { CountdownBadge } from '../ui/CountdownTimer'; + +function TierBadge({ tier }: { tier: string }) { + const styles: Record = { + T1: 'bg-tier-t1/10 text-tier-t1 border border-tier-t1/20', + T2: 'bg-tier-t2/10 text-tier-t2 border border-tier-t2/20', + T3: 'bg-tier-t3/10 text-tier-t3 border border-tier-t3/20', + }; + return ( + + {tier} + + ); +} + +interface BountyCardProps { + bounty: Bounty; +} + +export function BountyCard({ bounty }: BountyCardProps) { + const navigate = useNavigate(); + + const orgName = bounty.org_name ?? bounty.github_issue_url?.split('/')[3] ?? 'unknown'; + const repoName = bounty.repo_name ?? bounty.github_issue_url?.split('/')[4] ?? 'repo'; + const issueNumber = bounty.issue_number ?? bounty.github_issue_url?.split('/').pop(); + const skills = bounty.skills?.slice(0, 3) ?? []; + + const statusLabel = { + open: 'Open', + in_review: 'In Review', + funded: 'Funded', + completed: 'Completed', + cancelled: 'Cancelled', + }[bounty.status] ?? 'Open'; + + const statusColor = { + open: 'text-emerald', + in_review: 'text-magenta', + funded: 'text-status-info', + completed: 'text-text-muted', + cancelled: 'text-status-error', + }[bounty.status] ?? 'text-emerald'; + + const dotColor = { + open: 'bg-emerald', + in_review: 'bg-magenta', + funded: 'bg-status-info', + completed: 'bg-text-muted', + cancelled: 'bg-status-error', + }[bounty.status] ?? 'bg-emerald'; + + return ( + navigate(`/bounties/${bounty.id}`)} + className="relative rounded-xl border border-border bg-forge-900 p-5 cursor-pointer transition-colors duration-200 overflow-hidden group" + > + {/* Row 1: Repo + Tier */} +
+
+ {bounty.org_avatar_url && ( + + )} + + {orgName}/{repoName} + {issueNumber && #{issueNumber}} + +
+ +
+ + {/* Row 2: Title */} +

+ {bounty.title} +

+ + {/* Row 3: Language dots */} + {skills.length > 0 && ( +
+ {skills.map((lang) => ( + + + {lang} + + ))} +
+ )} + + {/* Separator */} +
+ + {/* Row 4: Reward + Meta */} +
+ + {formatCurrency(bounty.reward_amount, bounty.reward_token)} + +
+ + + {bounty.submission_count} PRs + + {bounty.deadline && ( + + + + + )} +
+
+ + {/* Status badge */} + + + {statusLabel} + + + ); +} diff --git a/frontend/src/components/bounty/BountyDetail.tsx b/frontend/src/components/bounty/BountyDetail.tsx index 65653fa8f..6c28ebf1d 100644 --- a/frontend/src/components/bounty/BountyDetail.tsx +++ b/frontend/src/components/bounty/BountyDetail.tsx @@ -1,161 +1,243 @@ -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 type { Bounty } from '../../types/bounty'; -import { timeLeft, timeAgo, formatCurrency, LANG_COLORS } from '../../lib/utils'; -import { useAuth } from '../../hooks/useAuth'; -import { SubmissionForm } from './SubmissionForm'; -import { fadeIn } from '../../lib/animations'; - -interface BountyDetailProps { - bounty: Bounty; -} - -export function BountyDetail({ bounty }: BountyDetailProps) { - const { isAuthenticated } = useAuth(); - const [submitting, setSubmitting] = useState(false); - const [copied, setCopied] = useState(false); - - const copyLink = () => { - navigator.clipboard.writeText(window.location.href).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }); - }; - - return ( - - {/* Back link */} - - Back to Bounties - - -
- {/* Main content */} -
- {/* Title + meta */} -
-
-
-
- {bounty.org_avatar_url && ( - - )} - {bounty.org_name}/{bounty.repo_name} - {bounty.issue_number && #{bounty.issue_number}} -
-

{bounty.title}

-
- -
- - {/* Skills */} - {bounty.skills?.length > 0 && ( -
- {bounty.skills.map((lang) => ( - - - {lang} - - ))} -
- )} - -

- {bounty.description} -

- - {bounty.github_issue_url && ( - - View GitHub Issue - - )} -
- - {/* Description / requirements */} -
-

Requirements

-

- Submit a working solution that addresses the bounty requirements above. - All submissions are reviewed by our AI pipeline (3 LLMs, pass threshold 7.0/10). -

-
- - {/* Submission form */} - {bounty.status === 'open' || bounty.status === 'funded' ? ( -
-

Submit Your Solution

- {isAuthenticated ? ( - - ) : ( -
-

Sign in with GitHub to submit a solution.

- - Sign in with GitHub - -
- )} -
- ) : null} -
- - {/* Sidebar */} -
- {/* Reward card */} -
-

Reward

-

- {formatCurrency(bounty.reward_amount, bounty.reward_token)} -

-
- - {/* Info card */} -
-
- Status - - {bounty.status} - -
-
- Tier - {bounty.tier ?? 'T1'} -
- {bounty.deadline && ( -
- Deadline - - {timeLeft(bounty.deadline)} - -
- )} -
- Submissions - - {bounty.submission_count} - -
-
- Posted - {timeAgo(bounty.created_at)} -
-
-
-
-
- ); -} +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 type { Bounty } from '../../types/bounty'; +import { timeAgo, formatCurrency, LANG_COLORS } from '../../lib/utils'; +import { useAuth } from '../../hooks/useAuth'; +import { SubmissionForm } from './SubmissionForm'; +import { fadeIn } from '../../lib/animations'; +import { Skeleton } from '../ui/Skeleton'; +import { CountdownTimer } from '../ui/CountdownTimer'; + +interface BountyDetailProps { + bounty: Bounty; +} + +export function BountyDetail({ bounty }: BountyDetailProps) { + const { isAuthenticated } = useAuth(); + const [submitting, setSubmitting] = useState(false); + const [copied, setCopied] = useState(false); + + const copyLink = () => { + navigator.clipboard.writeText(window.location.href).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + return ( + + {/* Back link */} + + Back to Bounties + + +
+ {/* Main content */} +
+ {/* Title + meta */} +
+
+
+
+ {bounty.org_avatar_url && ( + + )} + {bounty.org_name}/{bounty.repo_name} + {bounty.issue_number && #{bounty.issue_number}} +
+

{bounty.title}

+
+ +
+ + {/* Skills */} + {bounty.skills?.length > 0 && ( +
+ {bounty.skills.map((lang) => ( + + + {lang} + + ))} +
+ )} + +

+ {bounty.description} +

+ + {bounty.github_issue_url && ( + + View GitHub Issue + + )} +
+ + {/* Description / requirements */} +
+

Requirements

+

+ Submit a working solution that addresses the bounty requirements above. + All submissions are reviewed by our AI pipeline (3 LLMs, pass threshold 7.0/10). +

+
+ + {/* Submission form */} + {bounty.status === 'open' || bounty.status === 'funded' ? ( +
+

Submit Your Solution

+ {isAuthenticated ? ( + + ) : ( +
+

Sign in with GitHub to submit a solution.

+ + Sign in with GitHub + +
+ )} +
+ ) : null} +
+ + {/* Sidebar */} +
+ {/* Reward card */} +
+

Reward

+

+ {formatCurrency(bounty.reward_amount, bounty.reward_token)} +

+
+ + {/* Info card */} +
+
+ Status + + {bounty.status} + +
+
+ Tier + {bounty.tier ?? 'T1'} +
+ {bounty.deadline && ( +
+ Deadline + +
+ )} +
+ Submissions + + {bounty.submission_count} + +
+
+ Posted + {timeAgo(bounty.created_at)} +
+
+
+
+
+ ); +} + +// Skeleton for BountyDetail loading state +export function BountyDetailSkeleton() { + return ( +
+ {/* Back link */} + + +
+ {/* Main content */} +
+ {/* Title + meta */} +
+
+
+
+ + +
+ +
+ +
+ + {/* Skills */} +
+ + + +
+ +
+ + + +
+ + +
+ + {/* Requirements */} +
+ +
+ + +
+
+ + {/* Submission form */} +
+ +
+ + + +
+
+
+ + {/* Sidebar */} +
+ {/* Reward card */} +
+ + +
+ + {/* Info card */} +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + +
+ ))} +
+
+
+
+ ); +} diff --git a/frontend/src/components/ui/CountdownTimer.tsx b/frontend/src/components/ui/CountdownTimer.tsx new file mode 100644 index 000000000..ae49bfb53 --- /dev/null +++ b/frontend/src/components/ui/CountdownTimer.tsx @@ -0,0 +1,182 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { Clock } from 'lucide-react'; + +export interface CountdownTimerProps { + deadline: string | Date; + showIcon?: boolean; + compact?: boolean; + className?: string; +} + +interface TimeRemaining { + days: number; + hours: number; + minutes: number; + seconds: number; + total: number; +} + +type UrgencyLevel = 'normal' | 'warning' | 'urgent' | 'expired'; + +function calculateTimeRemaining(deadline: Date): TimeRemaining { + const now = new Date().getTime(); + const target = deadline.getTime(); + const total = target - 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)) / (1000 * 60 * 60)), + minutes: Math.floor((total % (1000 * 60 * 60)) / (1000 * 60)), + seconds: Math.floor((total % (1000 * 60)) / 1000), + total, + }; +} + +function getUrgencyLevel(time: TimeRemaining): UrgencyLevel { + if (time.total <= 0) return 'expired'; + if (time.total < 1000 * 60 * 60) return 'urgent'; // < 1 hour + if (time.total < 1000 * 60 * 60 * 24) return 'warning'; // < 24 hours + return 'normal'; +} + +const urgencyStyles: Record = { + normal: { + text: 'text-text-secondary', + bg: 'bg-forge-700', + }, + warning: { + text: 'text-amber-400', + bg: 'bg-amber-400/10', + }, + urgent: { + text: 'text-red-400', + bg: 'bg-red-400/10', + pulse: true, + }, + expired: { + text: 'text-text-muted', + bg: 'bg-forge-800', + }, +}; + +export function CountdownTimer({ + deadline, + showIcon = true, + compact = false, + className = '', +}: CountdownTimerProps) { + const deadlineDate = useMemo(() => new Date(deadline), [deadline]); + const [timeRemaining, setTimeRemaining] = useState(() => + calculateTimeRemaining(deadlineDate) + ); + + useEffect(() => { + const timer = setInterval(() => { + setTimeRemaining(calculateTimeRemaining(deadlineDate)); + }, 1000); + + return () => clearInterval(timer); + }, [deadlineDate]); + + const urgency = getUrgencyLevel(timeRemaining); + const styles = urgencyStyles[urgency]; + + if (urgency === 'expired') { + return ( + + {showIcon && } + Expired + + ); + } + + const { days, hours, minutes, seconds } = timeRemaining; + + if (compact) { + // Compact format: "2d 5h" or "5h 30m" or "30m 15s" + let display: string; + if (days > 0) { + display = `${days}d ${hours}h`; + } else if (hours > 0) { + display = `${hours}h ${minutes}m`; + } else { + display = `${minutes}m ${seconds}s`; + } + + return ( + + {showIcon && } + {display} + + ); + } + + // Full format: "2d 05h 30m 15s" + const pad = (n: number) => n.toString().padStart(2, '0'); + + return ( + + {showIcon && } + {days > 0 && {days}d} + {pad(hours)}h + {pad(minutes)}m + {pad(seconds)}s + + ); +} + +// Smaller inline variant for bounty cards +export function CountdownBadge({ deadline, className = '' }: { deadline: string | Date; className?: string }) { + const deadlineDate = useMemo(() => new Date(deadline), [deadline]); + const [timeRemaining, setTimeRemaining] = useState(() => + calculateTimeRemaining(deadlineDate) + ); + + useEffect(() => { + const timer = setInterval(() => { + setTimeRemaining(calculateTimeRemaining(deadlineDate)); + }, 1000); + return () => clearInterval(timer); + }, [deadlineDate]); + + const urgency = getUrgencyLevel(timeRemaining); + const styles = urgencyStyles[urgency]; + + if (urgency === 'expired') { + return ( + Expired + ); + } + + const { days, hours, minutes } = timeRemaining; + let display: string; + if (days > 0) { + display = `${days}d ${hours}h`; + } else if (hours > 0) { + display = `${hours}h ${minutes}m`; + } else { + display = `<1h`; + } + + return ( + + {display} + + ); +} \ No newline at end of file