Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/(dashboard)/leaderboard/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="container mx-auto px-4 py-8">
<LeaderboardSection />
</div>
);
}
88 changes: 88 additions & 0 deletions components/leaderboard/LeaderboardCards.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div
ref={parentRef}
className="h-[600px] overflow-auto space-y-4 px-2"
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const user = users[virtualRow.index];
return (
<div
key={virtualRow.index}
className={cn(
"absolute top-0 left-0 w-full p-4 mb-3 rounded-xl border transition-all",
user.isCurrentUser
? "bg-cyan-500/10 border-cyan-500/30 shadow-[0_0_15px_rgba(34,211,238,0.1)]"
: "bg-slate-900/40 border-slate-800"
)}
style={{
height: `72px`, // slightly less than estimate to allow gap
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div className="flex items-center gap-4">
<div className="text-sm font-mono text-slate-500 w-6">
{user.rank}
</div>
<Avatar className="h-10 w-10 border border-slate-700">
<AvatarImage src={user.avatarUrl} alt={user.name} />
<AvatarFallback>{user.name[0]}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className={cn("text-sm font-bold truncate", user.isCurrentUser ? "text-cyan-400" : "text-white")}>
{user.name}
</p>
<p className="text-[10px] text-slate-500 uppercase tracking-wider">
{user.predictions} predictions
</p>
</div>
<div className="text-right text-sm">
<p className="font-bold text-emerald-400">+{user.profit}</p>
<p className="text-[10px] text-slate-400">{user.winRate}% SR</p>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
87 changes: 87 additions & 0 deletions components/leaderboard/LeaderboardPodium.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-end justify-center gap-2 sm:gap-4 py-8 mb-8">
{/* 2nd Place */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="flex flex-col items-center"
>
<div className="relative mb-2">
<Avatar className="h-16 w-16 border-2 border-slate-300 ring-4 ring-slate-300/20">
<AvatarImage src={second?.avatarUrl} alt={second?.name} />
<AvatarFallback>{second?.name?.[0]}</AvatarFallback>
</Avatar>
<div className="absolute -top-2 -right-2 bg-slate-300 text-slate-900 text-xs font-bold w-6 h-6 rounded-full flex items-center justify-center">
2
</div>
</div>
<div className="bg-slate-800/40 backdrop-blur-sm border border-slate-700/50 rounded-t-lg w-20 h-24 flex flex-col items-center justify-center p-2 text-center">
<span className="text-xs font-semibold text-white truncate w-full">{second?.name}</span>
<span className="text-[10px] text-cyan-400">+{second?.profit}</span>
</div>
</motion.div>

{/* 1st Place */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center -mt-8"
>
<div className="relative mb-3">
<div className="absolute -top-6 left-1/2 -translate-x-1/2 text-2xl">👑</div>
<Avatar className="h-24 w-24 border-4 border-yellow-500 ring-8 ring-yellow-500/20 shadow-[0_0_20px_rgba(234,179,8,0.3)]">
<AvatarImage src={first?.avatarUrl} alt={first?.name} />
<AvatarFallback>{first?.name?.[0]}</AvatarFallback>
</Avatar>
<div className="absolute -top-2 -right-2 bg-yellow-500 text-slate-900 text-sm font-bold w-8 h-8 rounded-full flex items-center justify-center">
1
</div>
</div>
<div className="bg-slate-800/60 backdrop-blur-md border border-yellow-500/30 rounded-t-lg w-28 h-40 flex flex-col items-center justify-center p-3 text-center shadow-xl">
<span className="text-sm font-bold text-white truncate w-full">{first?.name}</span>
<span className="text-xs text-yellow-400 font-semibold">+{first?.profit} XLM</span>
<div className="mt-2 text-[10px] text-slate-400">
{first?.winRate}% Win Rate
</div>
</div>
</motion.div>

{/* 3rd Place */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="flex flex-col items-center"
>
<div className="relative mb-2">
<Avatar className="h-16 w-16 border-2 border-amber-600 ring-4 ring-amber-600/20">
<AvatarImage src={third?.avatarUrl} alt={third?.name} />
<AvatarFallback>{third?.name?.[0]}</AvatarFallback>
</Avatar>
<div className="absolute -top-2 -right-2 bg-amber-600 text-white text-xs font-bold w-6 h-6 rounded-full flex items-center justify-center">
3
</div>
</div>
<div className="bg-slate-800/40 backdrop-blur-sm border border-slate-700/50 rounded-t-lg w-20 h-20 flex flex-col items-center justify-center p-2 text-center">
<span className="text-xs font-semibold text-white truncate w-full">{third?.name}</span>
<span className="text-[10px] text-cyan-400">+{third?.profit}</span>
</div>
</motion.div>
</div>
);
}
65 changes: 65 additions & 0 deletions components/leaderboard/LeaderboardSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-8 pb-20">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-black tracking-tighter text-white uppercase italic">
Top <span className="text-cyan-400">Predictors</span>
</h1>
<p className="text-slate-400 text-sm">
Ranked by total profit and accuracy across all markets.
</p>
</div>

<Tabs defaultValue="all-time" className="w-full md:w-auto" onValueChange={setActiveTab}>
<TabsList className="bg-slate-900 border border-slate-800">
<TabsTrigger value="daily">Daily</TabsTrigger>
<TabsTrigger value="weekly">Weekly</TabsTrigger>
<TabsTrigger value="all-time">All-Time</TabsTrigger>
</TabsList>
</Tabs>
</div>

{/* Mobile-only Podium */}
<div className="md:hidden">
<LeaderboardPodium topThree={topThree} />
</div>

{/* Desktop view: Full table includes top 3 */}
<div className="hidden md:block">
<LeaderboardTable users={users} onUserVisibilityChange={setIsUserVisible} />
</div>

{/* Mobile view: Podium + Cards for others */}
<div className="md:hidden space-y-4">
<div className="px-2">
<h2 className="text-xs font-bold uppercase tracking-widest text-slate-500 mb-4">Rankings</h2>
<LeaderboardCards users={others} onUserVisibilityChange={setIsUserVisible} />
</div>
</div>

<YourRankChip user={currentUser} isVisible={isUserVisible} />
</div>
);
}
102 changes: 102 additions & 0 deletions components/leaderboard/LeaderboardTable.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div className="w-full bg-slate-950/50 rounded-2xl border border-slate-800 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead className="sticky top-0 z-10 bg-slate-900 shadow-sm">
<tr className="text-slate-400 text-xs font-semibold uppercase tracking-wider">
<th className="px-6 py-4">Rank</th>
<th className="px-6 py-4">User</th>
<th className="px-6 py-4">Profit (XLM)</th>
<th className="px-6 py-4">Win Rate</th>
<th className="px-6 py-4">Predictions</th>
</tr>
</thead>
</table>
</div>

<div
ref={parentRef}
className="h-[600px] overflow-auto scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent"
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const user = users[virtualRow.index];
return (
<div
key={virtualRow.index}
className={cn(
"absolute top-0 left-0 w-full flex items-center border-b border-slate-800/50 hover:bg-slate-800/20 transition-colors",
user.isCurrentUser && "bg-cyan-500/5 border-cyan-500/20"
)}
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div className="px-6 w-20 text-slate-400 font-mono text-sm">
#{user.rank}
</div>
<div className="px-6 flex-1 flex items-center gap-3">
<Avatar className="h-8 w-8 border border-slate-700">
<AvatarImage src={user.avatarUrl} alt={user.name} />
<AvatarFallback>{user.name[0]}</AvatarFallback>
</Avatar>
<span className={cn("text-sm font-medium", user.isCurrentUser ? "text-cyan-400" : "text-white")}>
{user.name}
</span>
</div>
<div className="px-6 w-36 text-sm font-semibold text-emerald-400">
+{user.profit.toLocaleString()}
</div>
<div className="px-6 w-32 text-sm text-slate-300">
{user.winRate}%
</div>
<div className="px-6 w-32 text-sm text-slate-400">
{user.predictions}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
Loading