132 lines
7.2 KiB
TypeScript
132 lines
7.2 KiB
TypeScript
|
|
import React, { useState, useEffect } from 'react';
|
|
import { Card } from '../ui/card';
|
|
import { LeaderboardEntry } from '../../types';
|
|
import { ChevronUp, ChevronDown, Minus, Crown, Loader2 } from 'lucide-react';
|
|
import { gamificationService } from '../../services/gamificationService';
|
|
import { logger } from '@/utils/logger';
|
|
|
|
export const LeaderboardView: React.FC = () => {
|
|
const [period, setPeriod] = useState<'weekly' | 'monthly' | 'all'>('weekly');
|
|
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const loadLeaderboard = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await gamificationService.getLeaderboard(period);
|
|
setLeaderboard(data);
|
|
} catch (e) {
|
|
logger.error('Error loading leaderboard', {
|
|
error: e instanceof Error ? e.message : String(e),
|
|
stack: e instanceof Error ? e.stack : undefined,
|
|
period,
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
loadLeaderboard();
|
|
}, [period]);
|
|
|
|
|
|
return (
|
|
<div className="space-y-8 animate-fadeIn pb-20 max-w-5xl mx-auto">
|
|
|
|
{/* Header */}
|
|
<div className="flex flex-col md:flex-row justify-between items-end border-b border-kodo-steel/50 pb-6 gap-4">
|
|
<div>
|
|
<h2 className="text-3xl font-display font-bold text-white mb-2">LEADERBOARD</h2>
|
|
<p className="text-gray-400 font-mono text-sm">Top producers dominating the network.</p>
|
|
</div>
|
|
|
|
<div className="flex bg-kodo-ink p-1 rounded-lg border border-kodo-steel">
|
|
{['weekly', 'monthly', 'all'].map(p => (
|
|
<button
|
|
key={p}
|
|
onClick={() => setPeriod(p as any)}
|
|
className={`px-4 py-2 rounded text-xs font-bold uppercase transition-all ${period === p ? 'bg-kodo-gold text-black shadow-lg' : 'text-gray-400 hover:text-white'}`}
|
|
>
|
|
{p === 'all' ? 'All Time' : p}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 text-kodo-cyan animate-spin" /></div>
|
|
) : (
|
|
<>
|
|
{/* Top 3 Podium (Visual) */}
|
|
{leaderboard.length >= 3 && (
|
|
<div className="grid grid-cols-3 gap-4 items-end mb-8 md:px-20">
|
|
{[leaderboard[1], leaderboard[0], leaderboard[2]].map((entry, i) => (
|
|
<div key={entry.userId} className={`flex flex-col items-center ${i === 1 ? '-mt-12 order-2' : i === 0 ? 'order-1' : 'order-3'}`}>
|
|
<div className="relative mb-4">
|
|
<div className={`w-20 h-20 md:w-24 md:h-24 rounded-full overflow-hidden border-4 ${i === 1 ? 'border-kodo-gold' : i === 0 ? 'border-gray-300' : 'border-orange-400'}`}>
|
|
<img src={entry.avatar} className="w-full h-full object-cover" />
|
|
</div>
|
|
{i === 1 && <Crown className="absolute -top-8 left-1/2 -translate-x-1/2 w-10 h-10 text-kodo-gold fill-current animate-bounce" />}
|
|
<div className="absolute -bottom-3 left-1/2 -translate-x-1/2 bg-black px-2 py-0.5 rounded-full text-xs font-bold border border-white/20">
|
|
{entry.rank}
|
|
</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="font-bold text-white text-lg">{entry.username}</div>
|
|
<div className="text-xs text-kodo-cyan">{entry.xp.toLocaleString()} XP</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Table */}
|
|
<Card variant="default" className="p-0 overflow-hidden">
|
|
<table className="w-full text-left">
|
|
<thead>
|
|
<tr className="border-b border-kodo-steel bg-kodo-ink text-xs font-bold text-gray-500 uppercase tracking-wider">
|
|
<th className="p-4 w-16 text-center">Rank</th>
|
|
<th className="p-4">Producer</th>
|
|
<th className="p-4">Level</th>
|
|
<th className="p-4 text-right">XP</th>
|
|
<th className="p-4 text-center">Trend</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-kodo-steel/30 text-sm">
|
|
{leaderboard.map(entry => (
|
|
<tr key={entry.userId} className="hover:bg-white/5 transition-colors group">
|
|
<td className="p-4 text-center font-bold font-mono text-gray-400">
|
|
#{entry.rank}
|
|
</td>
|
|
<td className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<img src={entry.avatar} className="w-8 h-8 rounded-full" />
|
|
<span className="font-bold text-white group-hover:text-kodo-cyan transition-colors">{entry.username}</span>
|
|
</div>
|
|
</td>
|
|
<td className="p-4">
|
|
<span className="bg-kodo-slate px-2 py-1 rounded text-xs font-mono text-gray-300">LVL {entry.level}</span>
|
|
</td>
|
|
<td className="p-4 text-right font-mono font-bold text-white">
|
|
{entry.xp.toLocaleString()}
|
|
</td>
|
|
<td className="p-4 text-center">
|
|
{entry.trend > 0 ? (
|
|
<span className="text-kodo-lime flex items-center justify-center gap-1"><ChevronUp className="w-4 h-4" /> {entry.trend}</span>
|
|
) : entry.trend < 0 ? (
|
|
<span className="text-kodo-red flex items-center justify-center gap-1"><ChevronDown className="w-4 h-4" /> {Math.abs(entry.trend)}</span>
|
|
) : (
|
|
<span className="text-gray-500 flex items-center justify-center"><Minus className="w-4 h-4" /></span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</Card>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|