Files
gridpilot.gg/apps/website/templates/TeamLeaderboardTemplate.tsx
2026-01-05 19:35:49 +01:00

374 lines
15 KiB
TypeScript

'use client';
import React from 'react';
import { Users, Trophy, Crown, Award, ArrowLeft, Medal, Percent, Hash, Globe, Languages, Target } from 'lucide-react';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import Heading from '@/components/ui/Heading';
import TopThreePodium from '@/components/teams/TopThreePodium';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import TeamRankingsFilter from '@/components/TeamRankingsFilter';
import Image from 'next/image';
import { getMediaUrl } from '@/lib/utilities/media';
// ============================================================================
// TYPES
// ============================================================================
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
interface TeamLeaderboardTemplateProps {
teams: TeamSummaryViewModel[];
searchQuery: string;
filterLevel: SkillLevel | 'all';
sortBy: SortBy;
onSearchChange: (query: string) => void;
onFilterLevelChange: (level: SkillLevel | 'all') => void;
onSortChange: (sort: SortBy) => void;
onTeamClick: (id: string) => void;
onBackToTeams: () => void;
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
const getSafeRating = (team: TeamSummaryViewModel): number => {
return 0;
};
const getSafeTotalWins = (team: TeamSummaryViewModel): number => {
const raw = team.totalWins;
const value = typeof raw === 'number' ? raw : 0;
return Number.isFinite(value) ? value : 0;
};
const getSafeTotalRaces = (team: TeamSummaryViewModel): number => {
const raw = team.totalRaces;
const value = typeof raw === 'number' ? raw : 0;
return Number.isFinite(value) ? value : 0;
};
const getMedalColor = (position: number) => {
switch (position) {
case 0:
return 'text-yellow-400';
case 1:
return 'text-gray-300';
case 2:
return 'text-amber-600';
default:
return 'text-gray-500';
}
};
const getMedalBg = (position: number) => {
switch (position) {
case 0:
return 'bg-gradient-to-br from-yellow-400/20 to-yellow-600/10 border-yellow-400/40';
case 1:
return 'bg-gradient-to-br from-gray-300/20 to-gray-400/10 border-gray-300/40';
case 2:
return 'bg-gradient-to-br from-amber-600/20 to-amber-700/10 border-amber-600/40';
default:
return 'bg-iron-gray/50 border-charcoal-outline';
}
};
// ============================================================================
// MAIN TEMPLATE COMPONENT
// ============================================================================
export default function TeamLeaderboardTemplate({
teams,
searchQuery,
filterLevel,
sortBy,
onSearchChange,
onFilterLevelChange,
onSortChange,
onTeamClick,
onBackToTeams,
}: TeamLeaderboardTemplateProps) {
// Filter and sort teams
const filteredAndSortedTeams = teams
.filter((team) => {
// Search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
if (!team.name.toLowerCase().includes(query) && !(team.description ?? '').toLowerCase().includes(query)) {
return false;
}
}
// Level filter
if (filterLevel !== 'all' && team.performanceLevel !== filterLevel) {
return false;
}
return true;
})
.sort((a, b) => {
switch (sortBy) {
case 'rating': {
const aRating = getSafeRating(a);
const bRating = getSafeRating(b);
return bRating - aRating;
}
case 'wins': {
const aWinsSort = getSafeTotalWins(a);
const bWinsSort = getSafeTotalWins(b);
return bWinsSort - aWinsSort;
}
case 'winRate': {
const aRaces = getSafeTotalRaces(a);
const bRaces = getSafeTotalRaces(b);
const aWins = getSafeTotalWins(a);
const bWins = getSafeTotalWins(b);
const aRate = aRaces > 0 ? aWins / aRaces : 0;
const bRate = bRaces > 0 ? bWins / bRaces : 0;
return bRate - aRate;
}
case 'races': {
const aRacesSort = getSafeTotalRaces(a);
const bRacesSort = getSafeTotalRaces(b);
return bRacesSort - aRacesSort;
}
default:
return 0;
}
});
return (
<div className="max-w-7xl mx-auto px-4 pb-12">
{/* Header */}
<div className="mb-8">
<Button
variant="secondary"
onClick={onBackToTeams}
className="flex items-center gap-2 mb-6"
>
<ArrowLeft className="w-4 h-4" />
Back to Teams
</Button>
<div className="flex items-center gap-4 mb-2">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-yellow-400/20 to-amber-600/10 border border-yellow-400/30">
<Award className="w-7 h-7 text-yellow-400" />
</div>
<div>
<Heading level={1} className="text-3xl lg:text-4xl">
Team Leaderboard
</Heading>
<p className="text-gray-400">Rankings of all teams by performance metrics</p>
</div>
</div>
</div>
{/* Filters and Search */}
<TeamRankingsFilter
searchQuery={searchQuery}
onSearchChange={onSearchChange}
filterLevel={filterLevel}
onFilterLevelChange={onFilterLevelChange}
sortBy={sortBy}
onSortChange={onSortChange}
/>
{/* Podium for Top 3 - only show when viewing by rating without filters */}
{sortBy === 'rating' && filterLevel === 'all' && !searchQuery && filteredAndSortedTeams.length >= 3 && (
<TopThreePodium teams={filteredAndSortedTeams} onClick={onTeamClick} />
)}
{/* Stats Summary */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
<div className="flex items-center gap-2 mb-1">
<Users className="w-4 h-4 text-purple-400" />
<span className="text-xs text-gray-500">Total Teams</span>
</div>
<p className="text-2xl font-bold text-white">{filteredAndSortedTeams.length}</p>
</div>
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
<div className="flex items-center gap-2 mb-1">
<Crown className="w-4 h-4 text-yellow-400" />
<span className="text-xs text-gray-500">Pro Teams</span>
</div>
<p className="text-2xl font-bold text-white">
{filteredAndSortedTeams.filter((t) => t.performanceLevel === 'pro').length}
</p>
</div>
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
<div className="flex items-center gap-2 mb-1">
<Trophy className="w-4 h-4 text-performance-green" />
<span className="text-xs text-gray-500">Total Wins</span>
</div>
<p className="text-2xl font-bold text-white">
{filteredAndSortedTeams.reduce<number>(
(sum, t) => sum + getSafeTotalWins(t),
0,
)}
</p>
</div>
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
<div className="flex items-center gap-2 mb-1">
<Target className="w-4 h-4 text-neon-aqua" />
<span className="text-xs text-gray-500">Total Races</span>
</div>
<p className="text-2xl font-bold text-white">
{filteredAndSortedTeams.reduce<number>(
(sum, t) => sum + getSafeTotalRaces(t),
0,
)}
</p>
</div>
</div>
{/* Leaderboard Table */}
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
{/* Table Header */}
<div className="grid grid-cols-12 gap-4 px-4 py-3 bg-iron-gray/50 border-b border-charcoal-outline text-xs font-medium text-gray-500 uppercase tracking-wider">
<div className="col-span-1 text-center">Rank</div>
<div className="col-span-4 lg:col-span-5">Team</div>
<div className="col-span-2 text-center hidden lg:block">Members</div>
<div className="col-span-2 lg:col-span-1 text-center">Rating</div>
<div className="col-span-2 lg:col-span-1 text-center">Wins</div>
<div className="col-span-2 text-center">Win Rate</div>
</div>
{/* Table Body */}
<div className="divide-y divide-charcoal-outline/50">
{filteredAndSortedTeams.map((team, index) => {
const levelConfig = ['beginner', 'intermediate', 'advanced', 'pro'].find((l) => l === team.performanceLevel);
const LevelIcon = levelConfig === 'pro' ? Crown : levelConfig === 'advanced' ? Crown : levelConfig === 'intermediate' ? Crown : () => null;
const totalRaces = getSafeTotalRaces(team);
const totalWins = getSafeTotalWins(team);
const winRate =
totalRaces > 0 ? ((totalWins / totalRaces) * 100).toFixed(1) : '0.0';
return (
<button
key={team.id}
type="button"
onClick={() => onTeamClick(team.id)}
className="grid grid-cols-12 gap-4 px-4 py-4 w-full text-left hover:bg-iron-gray/30 transition-colors group"
>
{/* Position */}
<div className="col-span-1 flex items-center justify-center">
<div
className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-bold border ${getMedalBg(index)} ${getMedalColor(index)}`}
>
{index < 3 ? (
<Medal className="w-4 h-4" />
) : (
index + 1
)}
</div>
</div>
{/* Team Info */}
<div className="col-span-4 lg:col-span-5 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-charcoal-outline border border-charcoal-outline overflow-hidden">
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={40}
height={40}
className="w-full h-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-white font-semibold truncate group-hover:text-purple-400 transition-colors">
{team.name}
</p>
<div className="flex items-center gap-2 text-xs text-gray-500 flex-wrap">
<span className={`${team.performanceLevel === 'pro' ? 'text-yellow-400' : team.performanceLevel === 'advanced' ? 'text-purple-400' : team.performanceLevel === 'intermediate' ? 'text-primary-blue' : 'text-green-400'}`}>
{team.performanceLevel}
</span>
{team.category && (
<span className="flex items-center gap-1 text-purple-400">
<span className="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
{team.category}
</span>
)}
{team.region && (
<span className="flex items-center gap-1 text-gray-400">
<Globe className="w-3 h-3 text-neon-aqua" />
{team.region}
</span>
)}
{team.languages && team.languages.length > 0 && (
<span className="flex items-center gap-1 text-gray-400">
<Languages className="w-3 h-3 text-purple-400" />
{team.languages.slice(0, 2).join(', ')}
{team.languages.length > 2 && ` +${team.languages.length - 2}`}
</span>
)}
{team.isRecruiting && (
<span className="flex items-center gap-1 text-performance-green">
<div className="w-1.5 h-1.5 rounded-full bg-performance-green animate-pulse" />
Recruiting
</span>
)}
</div>
</div>
</div>
{/* Members */}
<div className="col-span-2 items-center justify-center hidden lg:flex">
<span className="flex items-center gap-1 text-gray-400">
<Users className="w-4 h-4" />
{team.memberCount}
</span>
</div>
{/* Rating */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span
className={`font-mono font-semibold ${
sortBy === 'rating' ? 'text-purple-400' : 'text-white'
}`}
>
{getSafeRating(team).toLocaleString()}
</span>
</div>
{/* Wins */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className={`font-mono font-semibold ${sortBy === 'wins' ? 'text-purple-400' : 'text-white'}`}>
{getSafeTotalWins(team)}
</span>
</div>
{/* Win Rate */}
<div className="col-span-2 flex items-center justify-center">
<span className={`font-mono font-semibold ${sortBy === 'winRate' ? 'text-purple-400' : 'text-white'}`}>
{winRate}%
</span>
</div>
</button>
);
})}
</div>
{/* Empty State */}
{filteredAndSortedTeams.length === 0 && (
<div className="py-16 text-center">
<Trophy className="w-12 h-12 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No teams found</p>
<p className="text-sm text-gray-500">Try adjusting your filters or search query</p>
<Button
variant="secondary"
onClick={() => {
onSearchChange('');
onFilterLevelChange('all');
}}
className="mt-4"
>
Clear Filters
</Button>
</div>
)}
</div>
</div>
);
}