From 075410899b204334bdf7a7cb008e83e060142261 Mon Sep 17 00:00:00 2001 From: Isaac Onyemaechi Date: Fri, 30 Jan 2026 01:49:35 +0100 Subject: [PATCH 1/3] feat: introduce leaderboard page with filtering, table, and user rank display components. --- app/leaderboard/page.tsx | 90 ++++++++++ .../leaderboard/leaderboard-filters.tsx | 103 +++++++++++ components/leaderboard/leaderboard-table.tsx | 164 ++++++++++++++++++ components/leaderboard/rank-badge.tsx | 22 +++ components/leaderboard/streak-indicator.tsx | 28 +++ components/leaderboard/tier-badge.tsx | 19 ++ components/leaderboard/user-rank-sidebar.tsx | 125 +++++++++++++ types/bounty.ts | 10 -- 8 files changed, 551 insertions(+), 10 deletions(-) create mode 100644 app/leaderboard/page.tsx create mode 100644 components/leaderboard/leaderboard-filters.tsx create mode 100644 components/leaderboard/leaderboard-table.tsx create mode 100644 components/leaderboard/rank-badge.tsx create mode 100644 components/leaderboard/streak-indicator.tsx create mode 100644 components/leaderboard/tier-badge.tsx create mode 100644 components/leaderboard/user-rank-sidebar.tsx diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx new file mode 100644 index 0000000..072e19f --- /dev/null +++ b/app/leaderboard/page.tsx @@ -0,0 +1,90 @@ +"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"; + +export default function LeaderboardPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + + // Initialize filters from URL + const initialTimeframe = (searchParams.get("timeframe") as FiltersType["timeframe"]) || "ALL_TIME"; + const initialTier = (searchParams.get("tier") as ReputationTier) || undefined; + + const [filters, setFilters] = useState({ + timeframe: initialTimeframe, + tier: initialTier, + tags: [], + }); + + // Fake current user ID for demo purposes + // In a real app this would come from auth context + const currentUserId = "user-1"; + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + } = useLeaderboard(filters, 20); + + // Sync filters to URL + useEffect(() => { + const params = new URLSearchParams(); + if (filters.timeframe !== "ALL_TIME") params.set("timeframe", filters.timeframe); + if (filters.tier) params.set("tier", filters.tier); + + router.replace(`/leaderboard?${params.toString()}`, { scroll: false }); + }, [filters, 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 */} +
+ + + fetchNextPage()} + currentUserId={currentUserId} + /> +
+ + {/* Sidebar - User Rank */} +
+ +
+
+
+
+ ); +} diff --git a/components/leaderboard/leaderboard-filters.tsx b/components/leaderboard/leaderboard-filters.tsx new file mode 100644 index 0000000..f0eb023 --- /dev/null +++ b/components/leaderboard/leaderboard-filters.tsx @@ -0,0 +1,103 @@ +"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"; + +interface LeaderboardFiltersProps { + filters: FiltersType; + onFilterChange: (filters: FiltersType) => void; +} + +const TIMEFRAMES: { value: LeaderboardTimeframe; label: string }[] = [ + { value: "ALL_TIME", label: "All Time" }, + { value: "THIS_MONTH", label: "This Month" }, + { value: "THIS_WEEK", label: "This Week" }, +]; + +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" }, +]; + +export function LeaderboardFilters({ filters, onFilterChange }: LeaderboardFiltersProps) { + const updateFilter = (key: keyof FiltersType, value: unknown) => { + onFilterChange({ ...filters, [key]: value }); + }; + + 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 */} + + + {/* Clear Button */} + {hasActiveFilters && ( + + )} +
+ ); +} diff --git a/components/leaderboard/leaderboard-table.tsx b/components/leaderboard/leaderboard-table.tsx new file mode 100644 index 0000000..d21a3e2 --- /dev/null +++ b/components/leaderboard/leaderboard-table.tsx @@ -0,0 +1,164 @@ +"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; +} + +export function LeaderboardTable({ + entries, + isLoading, + hasNextPage, + isFetchingNextPage, + onLoadMore, + currentUserId, +}: LeaderboardTableProps) { + const loadMoreRef = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage) { + onLoadMore(); + } + }, + { threshold: 0.1 } + ); + + if (loadMoreRef.current) { + observer.observe(loadMoreRef.current); + } + + return () => { + observer.disconnect(); + }; + }, [hasNextPage, onLoadMore]); + + if (isLoading && entries.length === 0) { + return ( +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ ); + } + + return ( +
+ + + + RANK + CONTRIBUTOR + TIER + SCORE + COMPLETED + EARNINGS + STREAK + + + + {entries.map((entry) => { + const isCurrentUser = currentUserId === entry.contributor.userId; + + return ( + + +
+ +
+
+ +
+ + + {entry.contributor.displayName[0]} + +
+ + {entry.contributor.displayName} + {isCurrentUser && " (You)"} + +
+ {entry.contributor.tier} +
+ {/* Mobile tags */} +
+ {entry.contributor.topTags.slice(0, 1).map(tag => ( + {tag} + ))} +
+
+
+ {/* Desktop tags */} +
+ {entry.contributor.topTags.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..5971d60 --- /dev/null +++ b/components/leaderboard/streak-indicator.tsx @@ -0,0 +1,28 @@ +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} +
+
+ +

{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..061a387 --- /dev/null +++ b/components/leaderboard/user-rank-sidebar.tsx @@ -0,0 +1,125 @@ +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"; + +interface UserRankSidebarProps { + userId?: string; +} + +export function UserRankSidebar({ userId }: UserRankSidebarProps) { + const { data, isLoading } = useUserRank(userId); + + if (!userId) { + return ( + + +

Connect your wallet to see your rank

+
+
+ ); + } + + if (isLoading) { + return ( + + + + + + + + + + ); + } + + 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 + +
+ +
+
+ ); +} 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 From e5f741890a6af424b7564cb7905ae360697bb856 Mon Sep 17 00:00:00 2001 From: Isaac Onyemaechi Date: Fri, 30 Jan 2026 02:15:45 +0100 Subject: [PATCH 2/3] feat: Implement tag filtering, debounced filter updates, and enhanced error handling for leaderboard data and UI. --- app/leaderboard/page.tsx | 83 ++++++++++--- .../leaderboard/leaderboard-filters.tsx | 117 +++++++++++++++++- components/leaderboard/leaderboard-table.tsx | 39 ++++-- components/leaderboard/streak-indicator.tsx | 8 +- components/leaderboard/user-rank-sidebar.tsx | 17 ++- 5 files changed, 223 insertions(+), 41 deletions(-) diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx index 072e19f..ae98942 100644 --- a/app/leaderboard/page.tsx +++ b/app/leaderboard/page.tsx @@ -7,41 +7,72 @@ 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(); - // Initialize filters from URL - const initialTimeframe = (searchParams.get("timeframe") as FiltersType["timeframe"]) || "ALL_TIME"; - const initialTier = (searchParams.get("tier") as ReputationTier) || undefined; + // 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: [], + 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, - } = useLeaderboard(filters, 20); + isError, + error, + refetch + } = useLeaderboard(debouncedFilters, 20); - // Sync filters to URL + // Sync debounced filters to URL useEffect(() => { const params = new URLSearchParams(); - if (filters.timeframe !== "ALL_TIME") params.set("timeframe", filters.timeframe); - if (filters.tier) params.set("tier", filters.tier); + 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 }); - }, [filters, router]); + }, [debouncedFilters, router]); // Flatten infinite query data const entries = data?.pages.flatMap((page) => page.entries) || []; @@ -51,10 +82,10 @@ export default function LeaderboardPage() { {/* Hero Header */}
-

+

Leaderboard

-

+

Recognizing the top contributors in the ecosystem.

@@ -69,14 +100,28 @@ export default function LeaderboardPage() { onFilterChange={setFilters} /> - fetchNextPage()} - currentUserId={currentUserId} - /> + {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 index f0eb023..4c27cd2 100644 --- a/components/leaderboard/leaderboard-filters.tsx +++ b/components/leaderboard/leaderboard-filters.tsx @@ -14,19 +14,36 @@ import { 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; } -const TIMEFRAMES: { value: LeaderboardTimeframe; label: string }[] = [ +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" }, ]; -const TIERS: { value: ReputationTier; label: string }[] = [ +export const TIERS: { value: ReputationTier; label: string }[] = [ { value: "LEGEND", label: "Legend" }, { value: "EXPERT", label: "Expert" }, { value: "ESTABLISHED", label: "Established" }, @@ -34,11 +51,25 @@ const TIERS: { value: ReputationTier; label: string }[] = [ { 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", @@ -50,13 +81,13 @@ export function LeaderboardFilters({ filters, onFilterChange }: LeaderboardFilte const hasActiveFilters = filters.timeframe !== "ALL_TIME" || filters.tier || (filters.tags?.length || 0) > 0; return ( -
+
{/* Timeframe 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 && (