From 48f1c49ce99c4f2971c72b29fe4148971e268360 Mon Sep 17 00:00:00 2001 From: Raymond Abiola Date: Sat, 27 Jun 2026 18:16:10 +0100 Subject: [PATCH] feat: responsive leaderboard with podium and sticky your-rank chip --- app/(dashboard)/leaderboard/page.tsx | 14 +++ components/leaderboard/LeaderboardCards.tsx | 88 +++++++++++++++ components/leaderboard/LeaderboardPodium.tsx | 87 +++++++++++++++ components/leaderboard/LeaderboardSection.tsx | 65 +++++++++++ components/leaderboard/LeaderboardTable.tsx | 102 ++++++++++++++++++ components/leaderboard/YourRankChip.tsx | 58 ++++++++++ lib/leaderboard-data.ts | 19 ++++ 7 files changed, 433 insertions(+) create mode 100644 app/(dashboard)/leaderboard/page.tsx create mode 100644 components/leaderboard/LeaderboardCards.tsx create mode 100644 components/leaderboard/LeaderboardPodium.tsx create mode 100644 components/leaderboard/LeaderboardSection.tsx create mode 100644 components/leaderboard/LeaderboardTable.tsx create mode 100644 components/leaderboard/YourRankChip.tsx create mode 100644 lib/leaderboard-data.ts diff --git a/app/(dashboard)/leaderboard/page.tsx b/app/(dashboard)/leaderboard/page.tsx new file mode 100644 index 0000000..5932ef2 --- /dev/null +++ b/app/(dashboard)/leaderboard/page.tsx @@ -0,0 +1,14 @@ +import { LeaderboardSection } from "@/components/leaderboard/LeaderboardSection"; + +export const metadata = { + title: "Leaderboard | Predictify", + description: "View top predictors and their rankings on the Predictify platform.", +}; + +export default function LeaderboardPage() { + return ( +
+ +
+ ); +} diff --git a/components/leaderboard/LeaderboardCards.tsx b/components/leaderboard/LeaderboardCards.tsx new file mode 100644 index 0000000..3bdca13 --- /dev/null +++ b/components/leaderboard/LeaderboardCards.tsx @@ -0,0 +1,88 @@ +"use client"; + +import React, { useRef } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { cn } from "@/lib/utils"; +import { LeaderboardUser } from "@/lib/leaderboard-data"; + +interface LeaderboardCardsProps { + users: LeaderboardUser[]; + onUserVisibilityChange?: (isVisible: boolean) => void; +} + +export function LeaderboardCards({ users, onUserVisibilityChange }: LeaderboardCardsProps) { + const parentRef = useRef(null); + + const rowVirtualizer = useVirtualizer({ + count: users.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 80, + overscan: 5, + }); + + const currentUserIndex = users.findIndex(u => u.isCurrentUser); + + React.useEffect(() => { + if (onUserVisibilityChange) { + const isVisible = rowVirtualizer.getVirtualItems().some((vi: any) => vi.index === currentUserIndex); + onUserVisibilityChange(isVisible); + } + }, [rowVirtualizer.getVirtualItems(), currentUserIndex, onUserVisibilityChange]); + + return ( +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const user = users[virtualRow.index]; + return ( +
+
+
+ {user.rank} +
+ + + {user.name[0]} + +
+

+ {user.name} +

+

+ {user.predictions} predictions +

+
+
+

+{user.profit}

+

{user.winRate}% SR

+
+
+
+ ); + })} +
+
+ ); +} diff --git a/components/leaderboard/LeaderboardPodium.tsx b/components/leaderboard/LeaderboardPodium.tsx new file mode 100644 index 0000000..ed089e3 --- /dev/null +++ b/components/leaderboard/LeaderboardPodium.tsx @@ -0,0 +1,87 @@ +"use client"; + +import React from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { motion } from "framer-motion"; +import { LeaderboardUser } from "@/lib/leaderboard-data"; + +interface LeaderboardPodiumProps { + topThree: LeaderboardUser[]; +} + +export function LeaderboardPodium({ topThree }: LeaderboardPodiumProps) { + const [first, second, third] = topThree; + + return ( +
+ {/* 2nd Place */} + +
+ + + {second?.name?.[0]} + +
+ 2 +
+
+
+ {second?.name} + +{second?.profit} +
+
+ + {/* 1st Place */} + +
+
👑
+ + + {first?.name?.[0]} + +
+ 1 +
+
+
+ {first?.name} + +{first?.profit} XLM +
+ {first?.winRate}% Win Rate +
+
+
+ + {/* 3rd Place */} + +
+ + + {third?.name?.[0]} + +
+ 3 +
+
+
+ {third?.name} + +{third?.profit} +
+
+
+ ); +} diff --git a/components/leaderboard/LeaderboardSection.tsx b/components/leaderboard/LeaderboardSection.tsx new file mode 100644 index 0000000..01674a9 --- /dev/null +++ b/components/leaderboard/LeaderboardSection.tsx @@ -0,0 +1,65 @@ +"use client"; + +import React, { useState, useMemo } from "react"; +import { mockLeaderboardData } from "@/lib/leaderboard-data"; +import { LeaderboardPodium } from "./LeaderboardPodium"; +import { LeaderboardTable } from "./LeaderboardTable"; +import { LeaderboardCards } from "./LeaderboardCards"; +import { YourRankChip } from "./YourRankChip"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +export function LeaderboardSection() { + const [activeTab, setActiveTab] = useState("all-time"); + + // Realistically we'd fetch data here based on tab + const users = mockLeaderboardData; + const topThree = users.slice(0, 3); + const others = users.slice(3); + const currentUser = users.find(u => u.isCurrentUser)!; + + // Tracking user visibility for the sticky chip + const [isUserVisible, setIsUserVisible] = useState(true); + + return ( +
+
+
+

+ Top Predictors +

+

+ Ranked by total profit and accuracy across all markets. +

+
+ + + + Daily + Weekly + All-Time + + +
+ + {/* Mobile-only Podium */} +
+ +
+ + {/* Desktop view: Full table includes top 3 */} +
+ +
+ + {/* Mobile view: Podium + Cards for others */} +
+
+

Rankings

+ +
+
+ + +
+ ); +} diff --git a/components/leaderboard/LeaderboardTable.tsx b/components/leaderboard/LeaderboardTable.tsx new file mode 100644 index 0000000..dd043fb --- /dev/null +++ b/components/leaderboard/LeaderboardTable.tsx @@ -0,0 +1,102 @@ +"use client"; + +import React, { useRef } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { cn } from "@/lib/utils"; +import { LeaderboardUser } from "@/lib/leaderboard-data"; + +interface LeaderboardTableProps { + users: LeaderboardUser[]; + onUserVisibilityChange?: (isVisible: boolean) => void; +} + +export function LeaderboardTable({ users, onUserVisibilityChange }: LeaderboardTableProps) { + const parentRef = useRef(null); + + const rowVirtualizer = useVirtualizer({ + count: users.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 64, + overscan: 10, + }); + + const currentUserIndex = users.findIndex(u => u.isCurrentUser); + + React.useEffect(() => { + if (onUserVisibilityChange) { + const isVisible = rowVirtualizer.getVirtualItems().some((vi: any) => vi.index === currentUserIndex); + onUserVisibilityChange(isVisible); + } + }, [rowVirtualizer.getVirtualItems(), currentUserIndex, onUserVisibilityChange]); + + return ( +
+
+ + + + + + + + + + +
RankUserProfit (XLM)Win RatePredictions
+
+ +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const user = users[virtualRow.index]; + return ( +
+
+ #{user.rank} +
+
+ + + {user.name[0]} + + + {user.name} + +
+
+ +{user.profit.toLocaleString()} +
+
+ {user.winRate}% +
+
+ {user.predictions} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/components/leaderboard/YourRankChip.tsx b/components/leaderboard/YourRankChip.tsx new file mode 100644 index 0000000..0a28776 --- /dev/null +++ b/components/leaderboard/YourRankChip.tsx @@ -0,0 +1,58 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { motion, AnimatePresence } from "framer-motion"; +import { X } from "lucide-react"; +import { LeaderboardUser } from "@/lib/leaderboard-data"; +import { cn } from "@/lib/utils"; + +interface YourRankChipProps { + user: LeaderboardUser; + isVisible: boolean; +} + +export function YourRankChip({ user, isVisible }: YourRankChipProps) { + const [dismissed, setDismissed] = useState(false); + + // Reset dismissed state if user becomes visible again (so it can show up again if they scroll away later) + useEffect(() => { + if (isVisible) setDismissed(false); + }, [isVisible]); + + return ( + + {!isVisible && !dismissed && ( + +
+
+ Your Rank + #{user.rank} +
+ +
+ + + {user.name[0]} + + +{user.profit} XLM +
+ + +
+
+ )} +
+ ); +} diff --git a/lib/leaderboard-data.ts b/lib/leaderboard-data.ts new file mode 100644 index 0000000..3ba2d2b --- /dev/null +++ b/lib/leaderboard-data.ts @@ -0,0 +1,19 @@ +export interface LeaderboardUser { + rank: number; + name: string; + avatarUrl?: string; + profit: number; + winRate: number; + predictions: number; + isCurrentUser?: boolean; +} + +export const mockLeaderboardData: LeaderboardUser[] = Array.from({ length: 100 }, (_, i) => ({ + rank: i + 1, + name: i === 42 ? "You (User)" : `Predictor_${i + 1}`, + avatarUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`, + profit: Math.floor(Math.random() * 50000) / 100, + winRate: Math.floor(Math.random() * 40) + 50, // 50-90% + predictions: Math.floor(Math.random() * 500) + 10, + isCurrentUser: i === 42, +}));