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 */}
+
+
+
+
+ );
+}
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 (
+
+
+
+
+
+ | Rank |
+ User |
+ Profit (XLM) |
+ Win Rate |
+ Predictions |
+
+
+
+
+
+
+
+ {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,
+}));