diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx new file mode 100644 index 0000000..3a36395 --- /dev/null +++ b/app/leaderboard/page.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useLeaderboard } from "@/hooks/use-leaderboard"; +import { LeaderboardTable } from "@/components/leaderboard/leaderboard-table"; +import { LeaderboardFilters } from "@/components/leaderboard/leaderboard-filters"; +import { UserRankSidebar } from "@/components/leaderboard/user-rank-sidebar"; +import { LeaderboardFilters as FiltersType, ReputationTier } from "@/types/leaderboard"; +import { useState, useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { TIMEFRAMES, TIERS } from "@/components/leaderboard/leaderboard-filters"; +import { Button } from "@/components/ui/button"; +import { AlertCircle } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; + +export default function LeaderboardPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + + // Validate and initialize filters from URL + const rawTimeframe = searchParams.get("timeframe"); + const rawTier = searchParams.get("tier"); + + const initialTimeframe = TIMEFRAMES.some(t => t.value === rawTimeframe) + ? (rawTimeframe as FiltersType["timeframe"]) + : "ALL_TIME"; + + const initialTier = TIERS.some(t => t.value === rawTier) + ? (rawTier as ReputationTier) + : undefined; + + const initialTags = searchParams.get("tags") ? searchParams.get("tags")?.split(",") : []; + + const [filters, setFilters] = useState({ + timeframe: initialTimeframe, + tier: initialTier, + tags: initialTags || [], + }); + + // Fake current user ID for demo purposes + // In a real app this would come from auth context + const currentUserId = "user-1"; + + // Debounce filters to prevent rapid API calls/URL updates + const [debouncedFilters, setDebouncedFilters] = useState(filters); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedFilters(filters); + }, 400); + + return () => clearTimeout(timer); + }, [filters]); + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + error, + refetch + } = useLeaderboard(debouncedFilters, 20); + + // Sync debounced filters to URL + useEffect(() => { + const params = new URLSearchParams(); + if (debouncedFilters.timeframe !== "ALL_TIME") params.set("timeframe", debouncedFilters.timeframe); + if (debouncedFilters.tier) params.set("tier", debouncedFilters.tier); + if (debouncedFilters.tags && debouncedFilters.tags.length > 0) { + params.set("tags", debouncedFilters.tags.join(",")); + } + + router.replace(`/leaderboard?${params.toString()}`, { scroll: false }); + }, [debouncedFilters, router]); + + // Flatten infinite query data + const entries = data?.pages.flatMap((page) => page.entries) || []; + + return ( +
+ {/* Hero Header */} +
+
+

+ Leaderboard +

+

+ Recognizing the top contributors in the ecosystem. +

+
+
+ +
+
+ {/* Main Content - Table */} +
+ + + {isError ? ( + + + Error + +

Failed to load leaderboard data. {(error as Error)?.message}

+ +
+
+ ) : ( + fetchNextPage()} + currentUserId={currentUserId} + onRowClick={(entry) => router.push(`/user/${entry.contributor.userId}`)} + /> + )} +
+ + {/* Sidebar - User Rank */} +
+ +
+
+
+
+ ); +} diff --git a/components/leaderboard/leaderboard-filters.tsx b/components/leaderboard/leaderboard-filters.tsx new file mode 100644 index 0000000..03ae4e8 --- /dev/null +++ b/components/leaderboard/leaderboard-filters.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + LeaderboardFilters as FiltersType, + LeaderboardTimeframe, + ReputationTier +} from "@/types/leaderboard"; +import { FilterX } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { Check, PlusCircle } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface LeaderboardFiltersProps { + filters: FiltersType; + onFilterChange: (filters: FiltersType) => void; +} + +export const TIMEFRAMES: { value: LeaderboardTimeframe; label: string }[] = [ + { value: "ALL_TIME", label: "All Time" }, + { value: "THIS_MONTH", label: "This Month" }, + { value: "THIS_WEEK", label: "This Week" }, +]; + +export const TIERS: { value: ReputationTier; label: string }[] = [ + { value: "LEGEND", label: "Legend" }, + { value: "EXPERT", label: "Expert" }, + { value: "ESTABLISHED", label: "Established" }, + { value: "CONTRIBUTOR", label: "Contributor" }, + { value: "NEWCOMER", label: "Newcomer" }, +]; + +// Mock available tags for filter - in real app could be passed as prop +const AVAILABLE_TAGS = [ + "Auditing", "Smart Contracts", "DeFi", "Frontend", "Backend", + "Design", "Documentation", "Testing", "Security", "Zero Knowledge" +]; + +export function LeaderboardFilters({ filters, onFilterChange }: LeaderboardFiltersProps) { + const updateFilter = (key: keyof FiltersType, value: unknown) => { + onFilterChange({ ...filters, [key]: value }); + }; + + const handleTagToggle = (tag: string) => { + const currentTags = filters.tags || []; + const newTags = currentTags.includes(tag) + ? currentTags.filter((t) => t !== tag) + : [...currentTags, tag]; + updateFilter("tags", newTags); + }; + + const clearFilters = () => { + onFilterChange({ + timeframe: "ALL_TIME", + tier: undefined, + tags: [], + }); + }; + + const hasActiveFilters = filters.timeframe !== "ALL_TIME" || filters.tier || (filters.tags?.length || 0) > 0; + + return ( +
+ {/* Timeframe Select */} + + + {/* Tier Select */} + + + {/* Tags Multi-Select */} + + + + + + + + + No results found. + + {AVAILABLE_TAGS.map((tag) => { + const isSelected = filters.tags?.includes(tag); + return ( + handleTagToggle(tag)} + > +
+ +
+ {tag} +
+ ); + })} +
+ {(filters.tags?.length || 0) > 0 && ( + <> + + + updateFilter("tags", [])} + className="justify-center text-center" + > + Clear filters + + + + )} +
+
+
+
+ + {/* Clear Button */} + {hasActiveFilters && ( + + )} +
+ ); +} diff --git a/components/leaderboard/leaderboard-table.tsx b/components/leaderboard/leaderboard-table.tsx new file mode 100644 index 0000000..1c03140 --- /dev/null +++ b/components/leaderboard/leaderboard-table.tsx @@ -0,0 +1,177 @@ +"use client"; +import React, { useEffect, useRef } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { LeaderboardEntry } from "@/types/leaderboard"; +import { cn } from "@/lib/utils"; +import { RankBadge } from "./rank-badge"; +import { TierBadge } from "./tier-badge"; +import { StreakIndicator } from "./streak-indicator"; + +interface LeaderboardTableProps { + entries: LeaderboardEntry[]; + isLoading: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onLoadMore: () => void; + currentUserId?: string; + onRowClick?: (entry: LeaderboardEntry) => void; +} + +export function LeaderboardTable({ + entries, + isLoading, + hasNextPage, + isFetchingNextPage, + onLoadMore, + currentUserId, + onRowClick, +}: LeaderboardTableProps) { + const loadMoreRef = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + onLoadMore(); + } + }, + { threshold: 0.1 } + ); + + if (loadMoreRef.current) { + observer.observe(loadMoreRef.current); + } + + return () => { + observer.disconnect(); + }; + }, [hasNextPage, onLoadMore, isFetchingNextPage]); + + if (isLoading && entries.length === 0) { + return ( +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ ); + } + + const handleKeyDown = (e: React.KeyboardEvent, entry: LeaderboardEntry) => { + if (onRowClick && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onRowClick(entry); + } + }; + + return ( +
+ + + + RANK + CONTRIBUTOR + TIER + SCORE + COMPLETED + EARNINGS + STREAK + + + + {entries.map((entry) => { + const isCurrentUser = currentUserId === entry.contributor.userId; + + return ( + onRowClick(entry) : undefined} + onKeyDown={onRowClick ? (e) => handleKeyDown(e, entry) : undefined} + > + +
+ +
+
+ +
+ + + {entry.contributor.displayName[0]} + +
+ + {entry.contributor.displayName} + {isCurrentUser && " (You)"} + +
+ {entry.contributor.tier} +
+
+ {entry.contributor.topTags.slice(0, 3).map(tag => ( + {tag} + ))} +
+
+
+ {/* Desktop tags */} +
+ {entry.contributor.topTags.slice(0, 3).map(tag => ( + + {tag} + + ))} +
+
+ + + + + {entry.contributor.totalScore.toLocaleString()} + + + {entry.contributor.stats.totalCompleted} + + + ${entry.contributor.stats.totalEarnings.toLocaleString()} + + +
+ +
+
+
+ ); + })} + {isFetchingNextPage && ( + + +
+ Loading more... +
+
+
+ )} +
+
+ {hasNextPage &&
} +
+ ); +} diff --git a/components/leaderboard/rank-badge.tsx b/components/leaderboard/rank-badge.tsx new file mode 100644 index 0000000..3aff01d --- /dev/null +++ b/components/leaderboard/rank-badge.tsx @@ -0,0 +1,22 @@ +import { cn } from "@/lib/utils"; + +interface RankBadgeProps { + rank: number; + className?: string; +} + +export function RankBadge({ rank, className }: RankBadgeProps) { + if (rank <= 3) { + return ( +
+ {rank} +
+ ); + } + + return ( +
+ {rank} +
+ ); +} diff --git a/components/leaderboard/streak-indicator.tsx b/components/leaderboard/streak-indicator.tsx new file mode 100644 index 0000000..a4b2e55 --- /dev/null +++ b/components/leaderboard/streak-indicator.tsx @@ -0,0 +1,32 @@ +import { Flame } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +interface StreakIndicatorProps { + streak: number; + className?: string; +} + +export function StreakIndicator({ streak, className }: StreakIndicatorProps) { + if (streak === 0) return null; + + return ( + + + + + + +

{streak} week streak

+
+
+
+ ); +} diff --git a/components/leaderboard/tier-badge.tsx b/components/leaderboard/tier-badge.tsx new file mode 100644 index 0000000..241a5dd --- /dev/null +++ b/components/leaderboard/tier-badge.tsx @@ -0,0 +1,19 @@ +import { ReputationTier } from "@/types/leaderboard"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; + +interface TierBadgeProps { + tier: ReputationTier; + className?: string; +} + +export function TierBadge({ tier, className }: TierBadgeProps) { + return ( + + {tier} + + ); +} diff --git a/components/leaderboard/user-rank-sidebar.tsx b/components/leaderboard/user-rank-sidebar.tsx new file mode 100644 index 0000000..8c92785 --- /dev/null +++ b/components/leaderboard/user-rank-sidebar.tsx @@ -0,0 +1,159 @@ +import { useUserRank } from "@/hooks/use-leaderboard"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Trophy, TrendingUp, Award, Coins } from "lucide-react"; +import { TierBadge } from "./tier-badge"; +import { StreakIndicator } from "./streak-indicator"; +import { RankBadge } from "./rank-badge"; +import { Progress } from "@/components/ui/progress"; + +interface UserRankSidebarProps { + userId?: string; +} + +export function UserRankSidebar({ userId }: UserRankSidebarProps) { + const { data, isLoading, error } = useUserRank(userId); + + if (!userId) { + return ( + + +

Connect your wallet to see your rank

+
+
+ ); + } + + if (isLoading) { + return ( + + + + + + + + + + ); + } + + if (error) { + return ( + + +

Failed to load rank

+

{(error as Error).message || "Unknown error"}

+
+
+ ); + } + + if (!data) { + return ( + + + Rank not found + + + ); + } + + const { contributor, rank } = data; + + return ( + + + Your Rank + + + {/* Main Stats */} +
+
+ + + {contributor.displayName[0]} + +
+ +
+
+
+

{contributor.displayName}

+ +
+
+ + + + {/* Details Grid */} +
+
+
+ Score +
+
+ {contributor.totalScore.toLocaleString()} +
+
+ +
+
+ Earnings +
+
+ ${contributor.stats.totalEarnings.toLocaleString()} +
+
+ +
+
+ Completed +
+
+ {contributor.stats.totalCompleted} +
+
+
+
+ Rate +
+
+ {Math.round(contributor.stats.completionRate * 100)}% +
+
+
+ +
+ Current Streak + +
+ + {/* Progress to Next Tier */} + {(() => { + // TODO: remove mock values once API provides them + const currentPoints = contributor.stats.currentTierPoints ?? contributor.totalScore; + // Mock threshold logic for display if missing: next tier is roughly 2x current score or fixed steps + const nextThreshold = contributor.stats.nextTierThreshold ?? (contributor.totalScore * 1.5); + + if (nextThreshold > 0) { + const progressPercent = Math.min(100, Math.max(0, (currentPoints / nextThreshold) * 100)); + return ( +
+
+ Progress to Next Tier + {Math.round(progressPercent)}% +
+ +
+ ); + } + return null; + })()} + +
+
+ ); +} diff --git a/types/bounty.ts b/types/bounty.ts index 9e367ee..47d9887 100644 --- a/types/bounty.ts +++ b/types/bounty.ts @@ -40,16 +40,6 @@ export interface Bounty { lastActivityAt?: string // for anti-squatting claimExpiresAt?: string submissionsEndDate?: string // For competitions/applications - // Optional: Keep requirements/scope if needed for details view, - // but strictly adhering to User's type for now. - // I will add them as optional to avoid breaking existing UI logic too much if I can helper it, - // or I will just map them in components if they are not in the type. - // The user said "Expected Data Types", implying this is the shape. - // I will add requirements and scope as optional extra fields if they were used in the UI, - // or I will assume the description contains them or they are not part of this specific list view type. - // Existing code uses `requirements` and `scope`. I should probably keep them as optional to avoid losing data in the details view if possible, - // or I'll have to remove that UI section. - // Let's add them as optional to be safe and backward compatible with existing components. requirements?: string[] scope?: string milestones?: unknown[] // Optional milestone definition diff --git a/types/leaderboard.ts b/types/leaderboard.ts index 0a4118c..a31a14f 100644 --- a/types/leaderboard.ts +++ b/types/leaderboard.ts @@ -18,6 +18,8 @@ export interface ContributorStats { averageCompletionTime: number; // in hours currentStreak: number; // consecutive days/weeks depending on logic, usually completion streak longestStreak: number; + nextTierThreshold?: number; + currentTierPoints?: number; } export interface LeaderboardContributor {