diff --git a/app/bounty/lightning-round/page.tsx b/app/bounty/lightning-round/page.tsx new file mode 100644 index 0000000..f1ea75f --- /dev/null +++ b/app/bounty/lightning-round/page.tsx @@ -0,0 +1,342 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { + Zap, + Trophy, + Clock, + DollarSign, + Layers, + ArrowLeft, + ChevronRight, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { BountyCard } from "@/components/bounty/bounty-card"; +import { BountyListSkeleton } from "@/components/bounty/bounty-card-skeleton"; +import { + useLightningRounds, + useLightningRoundBounties, + useCountdown, +} from "@/hooks/use-lightning-rounds"; +import { cn } from "@/lib/utils"; +import { format } from "date-fns"; +import type { BountyFieldsFragment } from "@/lib/graphql/generated"; + +const CATEGORY_LABELS: Record = { + FIXED_PRICE: "Fixed Price", + MILESTONE_BASED: "Milestone Based", + COMPETITION: "Competition", +}; + +function StatCard({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: string; +}) { + return ( +
+
+ {icon} + {label} +
+
{value}
+
+ ); +} + +function CountdownBlock({ targetDate, label }: { targetDate: string | null; label: string }) { + const cd = useCountdown(targetDate); + if (cd.expired) return null; + + const pad = (n: number) => String(n).padStart(2, "0"); + + return ( +
+ + {label} + +
+ {pad(cd.days)}d + : + {pad(cd.hours)}h + : + {pad(cd.minutes)}m + : + {pad(cd.seconds)}s +
+
+ ); +} + +function ProgressTracker({ + completedCount, + total, +}: { + completedCount: number; + total: number; +}) { + const pct = total > 0 ? Math.round((completedCount / total) * 100) : 0; + return ( +
+
+ Round progress + + {completedCount} / {total} claimed + +
+
+
+
+
{pct}% complete
+
+ ); +} + +function CategorySection({ + category, + bounties, +}: { + category: string; + bounties: BountyFieldsFragment[]; +}) { + return ( +
+
+

+ {CATEGORY_LABELS[category] ?? category} +

+ + {bounties.length} + +
+
+ {bounties.map((bounty) => ( + + + + ))} +
+
+ ); +} + +function RoundSelector({ + rounds, + activeId, +}: { + rounds: { id: string; name: string; status: string }[]; + activeId: string; +}) { + return ( +
+ {rounds.map((r) => ( + + {r.name} + + ))} +
+ ); +} + +export default function LightningRoundPage() { + const searchParams = useSearchParams(); + const windowIdParam = searchParams.get("windowId"); + + const { rounds, activeRound, isLoading: roundsLoading } = useLightningRounds(); + + const currentRound = windowIdParam + ? rounds.find((r) => r.id === windowIdParam) ?? activeRound + : activeRound; + + const { + byCategory, + totalValue, + completedCount, + total, + isLoading: bountiesLoading, + isError, + } = useLightningRoundBounties(currentRound?.id ?? ""); + + const isActive = currentRound?.status.toLowerCase() === "active"; + const isEnded = + currentRound?.endDate && new Date(currentRound.endDate) < new Date(); + const countdownTarget = isActive ? currentRound?.endDate : currentRound?.startDate; + + const categoryEntries = Object.entries(byCategory).sort(([a], [b]) => + a.localeCompare(b) + ); + + return ( +
+ {/* Background glow */} +
+ +
+ {/* Back nav */} +
+ + + Back to Bounties + +
+ + {/* Hero header */} +
+
+ +
+
+
+
+ + + Lightning Round + +
+ {isActive && ( + + + Live Now + + )} + {isEnded && ( + + Ended + + )} +
+ + {roundsLoading ? ( + + ) : ( +

+ {currentRound?.name ?? "Lightning Rounds"} +

+ )} + +

+ High-volume curated bounty events occurring every 10 days — 20 to + 50 bounties across all skill categories. Compete, contribute, and + earn. +

+ + {currentRound && ( +
+ {currentRound.startDate && ( + + + {format(new Date(currentRound.startDate), "MMM d")} + {currentRound.endDate && + ` – ${format(new Date(currentRound.endDate), "MMM d, yyyy")}`} + + )} + +
+ )} +
+ + {/* Stats */} + {!roundsLoading && currentRound && ( +
+ } + label="Bounties" + value={String(total || currentRound.bountyCount)} + /> + } + label="Total Value" + value={`$${(totalValue || currentRound.totalValue).toLocaleString()}`} + /> + } + label="Completed" + value={String(completedCount)} + /> + } + label="Categories" + value={String(currentRound.categories.length || categoryEntries.length)} + /> +
+ )} +
+
+ + {/* Round selector */} + {rounds.length > 1 && ( +
+ +
+ )} + + {/* Progress tracker */} + {currentRound && total > 0 && ( +
+ +
+ )} + + {/* Bounty grid by category */} +
+ {bountiesLoading ? ( + + ) : isError ? ( +
+ Failed to load bounties for this round. +
+ ) : categoryEntries.length === 0 ? ( +
+ +

+ {currentRound ? "No bounties yet" : "No active round"} +

+

+ {currentRound + ? "Bounties for this round haven't been added yet. Check back soon." + : "There is no active or upcoming Lightning Round at the moment."} +

+ +
+ ) : ( + categoryEntries.map(([category, bounties]) => ( + + )) + )} +
+
+
+ ); +} diff --git a/app/bounty/page.tsx b/app/bounty/page.tsx index e371b5d..27701af 100644 --- a/app/bounty/page.tsx +++ b/app/bounty/page.tsx @@ -10,6 +10,7 @@ import { import { FiltersSidebar } from "@/components/bounty/filters-sidebar"; import { BountyToolbar } from "@/components/bounty/bounty-toolbar"; import { BountyGrid } from "@/components/bounty/bounty-grid"; +import { LightningRoundBanner } from "@/components/bounty/lightning-round-banner"; export default function BountiesPage() { const { data, isLoading, isError, error, refetch } = useBounties(); @@ -53,6 +54,7 @@ export default function BountiesPage() { />
+ {status.label} + {bounty.bountyWindow && ( + + + Lightning + + )}
{variant === "grid" && bounty.rewardAmount && ( diff --git a/components/bounty/filters-sidebar.tsx b/components/bounty/filters-sidebar.tsx index 556a70e..38372f5 100644 --- a/components/bounty/filters-sidebar.tsx +++ b/components/bounty/filters-sidebar.tsx @@ -21,6 +21,7 @@ import { import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { MiniLeaderboard } from "@/components/leaderboard/mini-leaderboard"; +import { LightningRoundSchedule } from "@/components/bounty/lightning-round-schedule"; interface FiltersSidebarProps { searchQuery: string; @@ -208,8 +209,9 @@ export function FiltersSidebar({
-
+
+
diff --git a/components/bounty/lightning-round-banner.tsx b/components/bounty/lightning-round-banner.tsx new file mode 100644 index 0000000..a9b6378 --- /dev/null +++ b/components/bounty/lightning-round-banner.tsx @@ -0,0 +1,158 @@ +"use client"; + +import Link from "next/link"; +import { Zap, ArrowRight, Clock, DollarSign, Layers } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { useLightningRounds, useCountdown } from "@/hooks/use-lightning-rounds"; + +function CountdownUnit({ value, label }: { value: number; label: string }) { + return ( +
+ + {String(value).padStart(2, "0")} + + + {label} + +
+ ); +} + +function CountdownSeparator() { + return ( + : + ); +} + +export function LightningRoundBanner({ className }: { className?: string }) { + const { activeRound, isLoading } = useLightningRounds(); + + const isActive = + activeRound?.status.toLowerCase() === "active"; + const targetDate = isActive ? activeRound?.endDate : activeRound?.startDate; + const countdown = useCountdown(targetDate ?? null); + + if (isLoading) { + return ( +
+ ); + } + + if (!activeRound) return null; + + return ( +
+ {/* Ambient glow */} +
+
+ +
+ {/* Left: Identity */} +
+
+
+ + + Lightning Round + +
+ {isActive ? ( + + + Live + + ) : ( + + Upcoming + + )} +
+ +

+ {activeRound.name} +

+

+ {isActive + ? "A curated burst of high-value bounties — claim your spot before time runs out." + : "The next Lightning Round is almost here. Get ready to compete."} +

+ + {/* Stats row */} +
+
+ + + + {activeRound.bountyCount} + {" "} + bounties + +
+
+ + + + ${activeRound.totalValue.toLocaleString()} + {" "} + total value + +
+
+ + + {isActive ? "Ends" : "Starts"}{" "} + {targetDate + ? new Date(targetDate).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + : "TBD"} + +
+
+
+ + {/* Right: Countdown + CTA */} +
+ {!countdown.expired && ( +
+ + + + + + + +
+ )} + + +
+
+
+ ); +} diff --git a/components/bounty/lightning-round-schedule.tsx b/components/bounty/lightning-round-schedule.tsx new file mode 100644 index 0000000..75b898d --- /dev/null +++ b/components/bounty/lightning-round-schedule.tsx @@ -0,0 +1,165 @@ +"use client"; + +import Link from "next/link"; +import { Zap, CalendarDays, CheckCircle2, Clock, ChevronRight } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import { useLightningRounds, type LightningRound } from "@/hooks/use-lightning-rounds"; +import { format, isPast, isFuture } from "date-fns"; + +function RoundStatusBadge({ round }: { round: LightningRound }) { + const status = round.status.toLowerCase(); + if (status === "active") { + return ( + + + Live + + ); + } + if (round.endDate && isPast(new Date(round.endDate))) { + return ( + + Ended + + ); + } + return ( + + Upcoming + + ); +} + +function RoundRow({ round }: { round: LightningRound }) { + const isEnded = round.endDate ? isPast(new Date(round.endDate)) : false; + const isUpcoming = round.startDate ? isFuture(new Date(round.startDate)) : false; + + return ( + + {/* Icon */} +
+ {isEnded ? ( + + ) : ( + + )} +
+ + {/* Info */} +
+
+ + {round.name} + + +
+
+ + + {round.startDate + ? format(new Date(round.startDate), "MMM d, yyyy") + : "TBD"} + + {round.bountyCount} bounties + ${round.totalValue.toLocaleString()} +
+
+ + + + ); +} + +function ScheduleSkeleton() { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ); +} + +interface LightningRoundScheduleProps { + className?: string; + maxItems?: number; +} + +export function LightningRoundSchedule({ + className, + maxItems = 3, +}: LightningRoundScheduleProps) { + const { rounds, isLoading } = useLightningRounds(); + + const displayed = rounds.slice(0, maxItems); + + return ( + + + + + Round Schedule + + + View All + + + + + {isLoading ? ( + + ) : displayed.length === 0 ? ( +
+ No upcoming rounds scheduled. +
+ ) : ( +
+ {displayed.map((round) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/hooks/use-lightning-rounds.ts b/hooks/use-lightning-rounds.ts new file mode 100644 index 0000000..a6628f7 --- /dev/null +++ b/hooks/use-lightning-rounds.ts @@ -0,0 +1,155 @@ +import { useMemo, useState, useEffect } from "react"; +import { useActiveBountiesQuery, useBountiesQuery } from "@/lib/graphql/generated"; +import type { BountyFieldsFragment } from "@/lib/graphql/generated"; + +export interface LightningRound { + id: string; + name: string; + status: string; + startDate: string | null; + endDate: string | null; + bounties: BountyFieldsFragment[]; + totalValue: number; + bountyCount: number; + categories: string[]; +} + +export interface CountdownTime { + days: number; + hours: number; + minutes: number; + seconds: number; + expired: boolean; +} + +/** + * Derives lightning round data from bounties that have a bountyWindow attached. + * Groups bounties by their associated BountyWindow into LightningRound objects. + */ +export function useLightningRounds() { + const { data, isLoading, isError, error, refetch } = useActiveBountiesQuery(); + + const rounds = useMemo(() => { + const bounties = data?.activeBounties ?? []; + const windowMap = new Map(); + + for (const bounty of bounties) { + if (!bounty.bountyWindow) continue; + const w = bounty.bountyWindow; + + if (!windowMap.has(w.id)) { + windowMap.set(w.id, { + id: w.id, + name: w.name, + status: w.status, + startDate: w.startDate ?? null, + endDate: w.endDate ?? null, + bounties: [], + totalValue: 0, + bountyCount: 0, + categories: [], + }); + } + + const round = windowMap.get(w.id)!; + round.bounties.push(bounty as BountyFieldsFragment); + round.totalValue += bounty.rewardAmount ?? 0; + round.bountyCount += 1; + + const category = bounty.type; + if (!round.categories.includes(category)) { + round.categories.push(category); + } + } + + return Array.from(windowMap.values()).sort((a, b) => { + if (!a.startDate) return 1; + if (!b.startDate) return -1; + return new Date(b.startDate).getTime() - new Date(a.startDate).getTime(); + }); + }, [data?.activeBounties]); + + const activeRound = useMemo( + () => rounds.find((r) => r.status.toLowerCase() === "active") ?? rounds[0] ?? null, + [rounds] + ); + + return { rounds, activeRound, isLoading, isError, error, refetch }; +} + +/** + * Fetches bounties that belong to a specific lightning round (bountyWindow). + */ +export function useLightningRoundBounties(windowId: string) { + const { data, isLoading, isError, error } = useBountiesQuery({ + query: { bountyWindowId: windowId, limit: 50 }, + }); + + const bounties = useMemo( + () => (data?.bounties.bounties ?? []) as BountyFieldsFragment[], + [data?.bounties.bounties] + ); + + const byCategory = useMemo(() => { + return bounties.reduce>((acc, b) => { + const cat = b.type; + if (!acc[cat]) acc[cat] = []; + acc[cat].push(b); + return acc; + }, {}); + }, [bounties]); + + const totalValue = useMemo( + () => bounties.reduce((sum, b) => sum + (b.rewardAmount ?? 0), 0), + [bounties] + ); + + const completedCount = useMemo( + () => bounties.filter((b) => b.status === "completed").length, + [bounties] + ); + + return { + bounties, + byCategory, + totalValue, + completedCount, + total: data?.bounties.total ?? 0, + isLoading, + isError, + error, + }; +} + +/** + * Live countdown to a target date. Updates every second on the client. + */ +export function useCountdown(targetDate: string | null): CountdownTime { + const getTime = (): CountdownTime => { + if (!targetDate) { + return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true }; + } + const diff = new Date(targetDate).getTime() - Date.now(); + if (diff <= 0) { + return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true }; + } + return { + days: Math.floor(diff / (1000 * 60 * 60 * 24)), + hours: Math.floor((diff / (1000 * 60 * 60)) % 24), + minutes: Math.floor((diff / (1000 * 60)) % 60), + seconds: Math.floor((diff / 1000) % 60), + expired: false, + }; + }; + + const [time, setTime] = useState(getTime); + + useEffect(() => { + if (!targetDate) return; + const interval = setInterval(() => setTime(getTime()), 1000); + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [targetDate]); + + return time; +}