fix e2e
This commit is contained in:
816
apps/website/templates/DriverProfileTemplate.tsx
Normal file
816
apps/website/templates/DriverProfileTemplate.tsx
Normal file
@@ -0,0 +1,816 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
User,
|
||||
Trophy,
|
||||
Star,
|
||||
Calendar,
|
||||
Users,
|
||||
Flag,
|
||||
Award,
|
||||
TrendingUp,
|
||||
UserPlus,
|
||||
ExternalLink,
|
||||
Target,
|
||||
Zap,
|
||||
Clock,
|
||||
Medal,
|
||||
Crown,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
Twitter,
|
||||
Youtube,
|
||||
Twitch,
|
||||
MessageCircle,
|
||||
ArrowLeft,
|
||||
BarChart3,
|
||||
Shield,
|
||||
Percent,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import { CircularProgress } from '@/components/drivers/CircularProgress';
|
||||
import { HorizontalBarChart } from '@/components/drivers/HorizontalBarChart';
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
import type { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel';
|
||||
|
||||
type ProfileTab = 'overview' | 'stats';
|
||||
|
||||
interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface SocialHandle {
|
||||
platform: 'twitter' | 'youtube' | 'twitch' | 'discord';
|
||||
handle: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Achievement {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
|
||||
rarity: 'common' | 'rare' | 'epic' | 'legendary';
|
||||
earnedAt: Date;
|
||||
}
|
||||
|
||||
interface DriverExtendedProfile {
|
||||
socialHandles: SocialHandle[];
|
||||
achievements: Achievement[];
|
||||
racingStyle: string;
|
||||
favoriteTrack: string;
|
||||
favoriteCar: string;
|
||||
timezone: string;
|
||||
availableHours: string;
|
||||
lookingForTeam: boolean;
|
||||
openToRequests: boolean;
|
||||
}
|
||||
|
||||
interface TeamMembershipInfo {
|
||||
team: Team;
|
||||
role: string;
|
||||
joinedAt: Date;
|
||||
}
|
||||
|
||||
interface DriverProfileTemplateProps {
|
||||
driverProfile: DriverProfileViewModel;
|
||||
allTeamMemberships: TeamMembershipInfo[];
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
onBackClick: () => void;
|
||||
onAddFriend: () => void;
|
||||
friendRequestSent: boolean;
|
||||
activeTab: ProfileTab;
|
||||
setActiveTab: (tab: ProfileTab) => void;
|
||||
isSponsorMode?: boolean;
|
||||
sponsorInsights?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function getCountryFlag(countryCode: string): string {
|
||||
const code = countryCode.toUpperCase();
|
||||
if (code.length === 2) {
|
||||
const codePoints = [...code].map(char => 127397 + char.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
}
|
||||
return '🏁';
|
||||
}
|
||||
|
||||
function getRarityColor(rarity: Achievement['rarity']) {
|
||||
switch (rarity) {
|
||||
case 'common':
|
||||
return 'text-gray-400 bg-gray-400/10 border-gray-400/30';
|
||||
case 'rare':
|
||||
return 'text-primary-blue bg-primary-blue/10 border-primary-blue/30';
|
||||
case 'epic':
|
||||
return 'text-purple-400 bg-purple-400/10 border-purple-400/30';
|
||||
case 'legendary':
|
||||
return 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30';
|
||||
}
|
||||
}
|
||||
|
||||
function getAchievementIcon(icon: Achievement['icon']) {
|
||||
switch (icon) {
|
||||
case 'trophy':
|
||||
return Trophy;
|
||||
case 'medal':
|
||||
return Medal;
|
||||
case 'star':
|
||||
return Star;
|
||||
case 'crown':
|
||||
return Crown;
|
||||
case 'target':
|
||||
return Target;
|
||||
case 'zap':
|
||||
return Zap;
|
||||
}
|
||||
}
|
||||
|
||||
function getSocialIcon(platform: SocialHandle['platform']) {
|
||||
switch (platform) {
|
||||
case 'twitter':
|
||||
return Twitter;
|
||||
case 'youtube':
|
||||
return Youtube;
|
||||
case 'twitch':
|
||||
return Twitch;
|
||||
case 'discord':
|
||||
return MessageCircle;
|
||||
}
|
||||
}
|
||||
|
||||
function getSocialColor(platform: SocialHandle['platform']) {
|
||||
switch (platform) {
|
||||
case 'twitter':
|
||||
return 'hover:text-sky-400 hover:bg-sky-400/10';
|
||||
case 'youtube':
|
||||
return 'hover:text-red-500 hover:bg-red-500/10';
|
||||
case 'twitch':
|
||||
return 'hover:text-purple-400 hover:bg-purple-400/10';
|
||||
case 'discord':
|
||||
return 'hover:text-indigo-400 hover:bg-indigo-400/10';
|
||||
}
|
||||
}
|
||||
|
||||
export function DriverProfileTemplate({
|
||||
driverProfile,
|
||||
allTeamMemberships,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
onBackClick,
|
||||
onAddFriend,
|
||||
friendRequestSent,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
isSponsorMode = false,
|
||||
sponsorInsights = null,
|
||||
}: DriverProfileTemplateProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-gray-400">Loading driver profile...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !driverProfile?.currentDriver) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<Card className="text-center py-12">
|
||||
<User className="w-16 h-16 text-gray-600 mx-auto mb-4" />
|
||||
<div className="text-warning-amber mb-4">{error || 'Driver not found'}</div>
|
||||
<Button variant="secondary" onClick={onBackClick}>
|
||||
Back to Drivers
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const extendedProfile: DriverExtendedProfile = driverProfile.extendedProfile ? {
|
||||
socialHandles: driverProfile.extendedProfile.socialHandles,
|
||||
achievements: driverProfile.extendedProfile.achievements.map((achievement) => ({
|
||||
id: achievement.id,
|
||||
title: achievement.title,
|
||||
description: achievement.description,
|
||||
icon: achievement.icon,
|
||||
rarity: achievement.rarity,
|
||||
earnedAt: new Date(achievement.earnedAt),
|
||||
})),
|
||||
racingStyle: driverProfile.extendedProfile.racingStyle,
|
||||
favoriteTrack: driverProfile.extendedProfile.favoriteTrack,
|
||||
favoriteCar: driverProfile.extendedProfile.favoriteCar,
|
||||
timezone: driverProfile.extendedProfile.timezone,
|
||||
availableHours: driverProfile.extendedProfile.availableHours,
|
||||
lookingForTeam: driverProfile.extendedProfile.lookingForTeam,
|
||||
openToRequests: driverProfile.extendedProfile.openToRequests,
|
||||
} : {
|
||||
socialHandles: [],
|
||||
achievements: [],
|
||||
racingStyle: 'Unknown',
|
||||
favoriteTrack: 'Unknown',
|
||||
favoriteCar: 'Unknown',
|
||||
timezone: 'UTC',
|
||||
availableHours: 'Flexible',
|
||||
lookingForTeam: false,
|
||||
openToRequests: false,
|
||||
};
|
||||
|
||||
const stats = driverProfile?.stats || null;
|
||||
const globalRank = driverProfile?.currentDriver?.globalRank || 1;
|
||||
const driver = driverProfile.currentDriver;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 pb-12 space-y-6">
|
||||
{/* Back Navigation */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBackClick}
|
||||
className="flex items-center gap-2 mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Drivers
|
||||
</Button>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Drivers', href: '/drivers' },
|
||||
{ label: driver.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Sponsor Insights Card */}
|
||||
{isSponsorMode && sponsorInsights}
|
||||
|
||||
{/* Hero Header Section */}
|
||||
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-iron-gray/80 via-iron-gray/60 to-deep-graphite border border-charcoal-outline">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative p-6 md:p-8">
|
||||
<div className="flex flex-col md:flex-row md:items-start gap-6">
|
||||
{/* Avatar */}
|
||||
<div className="relative">
|
||||
<div className="w-28 h-28 md:w-36 md:h-36 rounded-2xl bg-gradient-to-br from-primary-blue to-purple-600 p-1 shadow-xl shadow-primary-blue/20">
|
||||
<div className="w-full h-full rounded-xl overflow-hidden bg-iron-gray">
|
||||
<Image
|
||||
src={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
|
||||
alt={driver.name}
|
||||
width={144}
|
||||
height={144}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Driver Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white">{driver.name}</h1>
|
||||
<span className="text-4xl" aria-label={`Country: ${driver.country}`}>
|
||||
{getCountryFlag(driver.country)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Rating and Rank */}
|
||||
<div className="flex flex-wrap items-center gap-4 mb-4">
|
||||
{stats && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/30">
|
||||
<Star className="w-4 h-4 text-primary-blue" />
|
||||
<span className="font-mono font-bold text-primary-blue">{stats.rating}</span>
|
||||
<span className="text-xs text-gray-400">Rating</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-400/10 border border-yellow-400/30">
|
||||
<Trophy className="w-4 h-4 text-yellow-400" />
|
||||
<span className="font-mono font-bold text-yellow-400">#{globalRank}</span>
|
||||
<span className="text-xs text-gray-400">Global</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-400">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Globe className="w-4 h-4" />
|
||||
iRacing: {driver.iracingId}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Joined{' '}
|
||||
{new Date(driver.joinedAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="w-4 h-4" />
|
||||
{extendedProfile.timezone}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onAddFriend}
|
||||
disabled={friendRequestSent}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Handles */}
|
||||
{extendedProfile.socialHandles.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-charcoal-outline/50">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-gray-500 mr-2">Connect:</span>
|
||||
{extendedProfile.socialHandles.map((social: SocialHandle) => {
|
||||
const Icon = getSocialIcon(social.platform);
|
||||
return (
|
||||
<a
|
||||
key={social.platform}
|
||||
href={social.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg bg-iron-gray/50 border border-charcoal-outline text-gray-400 transition-all ${getSocialColor(social.platform)}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="text-sm">{social.handle}</span>
|
||||
<ExternalLink className="w-3 h-3 opacity-50" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bio Section */}
|
||||
{driver.bio && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-primary-blue" />
|
||||
About
|
||||
</h2>
|
||||
<p className="text-gray-300 leading-relaxed">{driver.bio}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Team Memberships */}
|
||||
{allTeamMemberships.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-purple-400" />
|
||||
Team Memberships
|
||||
<span className="text-sm text-gray-500 font-normal">({allTeamMemberships.length})</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{allTeamMemberships.map((membership) => (
|
||||
<Link
|
||||
key={membership.team.id}
|
||||
href={`/teams/${membership.team.id}`}
|
||||
className="flex items-center gap-4 p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline hover:border-purple-400/30 hover:bg-iron-gray/50 transition-all group"
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-600/20 border border-purple-600/30">
|
||||
<Users className="w-6 h-6 text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-semibold truncate group-hover:text-purple-400 transition-colors">
|
||||
{membership.team.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span className="px-2 py-0.5 rounded-full bg-purple-600/20 text-purple-400 capitalize">
|
||||
{membership.role}
|
||||
</span>
|
||||
<span>
|
||||
Since {membership.joinedAt.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500 group-hover:text-purple-400 transition-colors" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Performance Overview with Diagrams */}
|
||||
{stats && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-neon-aqua" />
|
||||
Performance Overview
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* Circular Progress Charts */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex gap-6 mb-4">
|
||||
<CircularProgress
|
||||
value={stats.wins}
|
||||
max={stats.totalRaces}
|
||||
label="Win Rate"
|
||||
color="text-performance-green"
|
||||
/>
|
||||
<CircularProgress
|
||||
value={stats.podiums}
|
||||
max={stats.totalRaces}
|
||||
label="Podium Rate"
|
||||
color="text-warning-amber"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<CircularProgress
|
||||
value={stats.consistency ?? 0}
|
||||
max={100}
|
||||
label="Consistency"
|
||||
color="text-primary-blue"
|
||||
/>
|
||||
<CircularProgress
|
||||
value={stats.totalRaces - stats.dnfs}
|
||||
max={stats.totalRaces}
|
||||
label="Finish Rate"
|
||||
color="text-neon-aqua"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bar chart and key metrics */}
|
||||
<div className="md:col-span-2">
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-4 flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
Results Breakdown
|
||||
</h3>
|
||||
<HorizontalBarChart
|
||||
data={[
|
||||
{ label: 'Wins', value: stats.wins, color: 'bg-performance-green' },
|
||||
{ label: 'Podiums (2nd-3rd)', value: stats.podiums - stats.wins, color: 'bg-warning-amber' },
|
||||
{ label: 'DNFs', value: stats.dnfs, color: 'bg-red-500' },
|
||||
]}
|
||||
maxValue={stats.totalRaces}
|
||||
/>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="w-4 h-4 text-performance-green" />
|
||||
<span className="text-xs text-gray-500 uppercase">Best Finish</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-performance-green">P{stats.bestFinish}</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Target className="w-4 h-4 text-primary-blue" />
|
||||
<span className="text-xs text-gray-500 uppercase">Avg Finish</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-primary-blue">
|
||||
P{(stats.avgFinish ?? 0).toFixed(1)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex items-center gap-1 p-1 rounded-xl bg-iron-gray/50 border border-charcoal-outline w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === 'overview'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-iron-gray'
|
||||
}`}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('stats')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === 'stats'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-iron-gray'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
Detailed Stats
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<>
|
||||
{/* Stats and Profile Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Career Stats */}
|
||||
<Card className="lg:col-span-2">
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-performance-green" />
|
||||
Career Statistics
|
||||
</h2>
|
||||
{stats ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
|
||||
<div className="text-3xl font-bold text-white mb-1">{stats.totalRaces}</div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wider">Races</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
|
||||
<div className="text-3xl font-bold text-performance-green mb-1">{stats.wins}</div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wider">Wins</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
|
||||
<div className="text-3xl font-bold text-warning-amber mb-1">{stats.podiums}</div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wider">Podiums</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
|
||||
<div className="text-3xl font-bold text-primary-blue mb-1">{stats.consistency}%</div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wider">Consistency</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400 text-sm">No race statistics available yet.</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Racing Preferences */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Flag className="w-5 h-5 text-neon-aqua" />
|
||||
Racing Profile
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wider">Racing Style</span>
|
||||
<p className="text-white font-medium">{extendedProfile.racingStyle}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wider">Favorite Track</span>
|
||||
<p className="text-white font-medium">{extendedProfile.favoriteTrack}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wider">Favorite Car</span>
|
||||
<p className="text-white font-medium">{extendedProfile.favoriteCar}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wider">Available</span>
|
||||
<p className="text-white font-medium">{extendedProfile.availableHours}</p>
|
||||
</div>
|
||||
|
||||
{/* Status badges */}
|
||||
<div className="pt-4 border-t border-charcoal-outline/50 space-y-2">
|
||||
{extendedProfile.lookingForTeam && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-performance-green/10 border border-performance-green/30">
|
||||
<Users className="w-4 h-4 text-performance-green" />
|
||||
<span className="text-sm text-performance-green font-medium">Looking for Team</span>
|
||||
</div>
|
||||
)}
|
||||
{extendedProfile.openToRequests && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-primary-blue/10 border border-primary-blue/30">
|
||||
<UserPlus className="w-4 h-4 text-primary-blue" />
|
||||
<span className="text-sm text-primary-blue font-medium">Open to Friend Requests</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Achievements */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Award className="w-5 h-5 text-yellow-400" />
|
||||
Achievements
|
||||
<span className="ml-auto text-sm text-gray-500">{extendedProfile.achievements.length} earned</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{extendedProfile.achievements.map((achievement: Achievement) => {
|
||||
const Icon = getAchievementIcon(achievement.icon);
|
||||
const rarityClasses = getRarityColor(achievement.rarity);
|
||||
return (
|
||||
<div
|
||||
key={achievement.id}
|
||||
className={`p-4 rounded-xl border ${rarityClasses} transition-all hover:scale-105`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${rarityClasses.split(' ')[1]}`}>
|
||||
<Icon className={`w-5 h-5 ${rarityClasses.split(' ')[0]}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-semibold text-sm">{achievement.title}</p>
|
||||
<p className="text-gray-400 text-xs mt-0.5">{achievement.description}</p>
|
||||
<p className="text-gray-500 text-xs mt-1">
|
||||
{achievement.earnedAt.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Friends Preview */}
|
||||
{driverProfile.socialSummary.friends.length > 0 && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-purple-400" />
|
||||
Friends
|
||||
<span className="text-sm text-gray-500 font-normal">({driverProfile.socialSummary.friends.length})</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{driverProfile.socialSummary.friends.slice(0, 8).map((friend) => (
|
||||
<Link
|
||||
key={friend.id}
|
||||
href={`/drivers/${friend.id}`}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline hover:border-purple-400/30 hover:bg-iron-gray transition-all"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full overflow-hidden bg-gradient-to-br from-primary-blue to-purple-600">
|
||||
<Image
|
||||
src={friend.avatarUrl || mediaConfig.avatars.defaultFallback}
|
||||
alt={friend.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-white">{friend.name}</span>
|
||||
<span className="text-lg">{getCountryFlag(friend.country)}</span>
|
||||
</Link>
|
||||
))}
|
||||
{driverProfile.socialSummary.friends.length > 8 && (
|
||||
<div className="flex items-center px-3 py-2 text-sm text-gray-400">+{driverProfile.socialSummary.friends.length - 8} more</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'stats' && stats && (
|
||||
<div className="space-y-6">
|
||||
{/* Detailed Performance Metrics */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-primary-blue" />
|
||||
Detailed Performance Metrics
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Performance Bars */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-4">Results Breakdown</h3>
|
||||
<HorizontalBarChart
|
||||
data={[
|
||||
{ label: 'Wins', value: stats.wins, color: 'bg-performance-green' },
|
||||
{ label: 'Podiums (2nd-3rd)', value: stats.podiums - stats.wins, color: 'bg-warning-amber' },
|
||||
{ label: 'DNFs', value: stats.dnfs, color: 'bg-red-500' },
|
||||
]}
|
||||
maxValue={stats.totalRaces}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Percent className="w-4 h-4 text-performance-green" />
|
||||
<span className="text-xs text-gray-500 uppercase">Win Rate</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-performance-green">
|
||||
{((stats.wins / stats.totalRaces) * 100).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Percent className="w-4 h-4 text-warning-amber" />
|
||||
<span className="text-xs text-gray-500 uppercase">Podium Rate</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-warning-amber">
|
||||
{((stats.podiums / stats.totalRaces) * 100).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Activity className="w-4 h-4 text-primary-blue" />
|
||||
<span className="text-xs text-gray-500 uppercase">Consistency</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-primary-blue">{stats.consistency}%</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Zap className="w-4 h-4 text-neon-aqua" />
|
||||
<span className="text-xs text-gray-500 uppercase">Finish Rate</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-neon-aqua">
|
||||
{(((stats.totalRaces - stats.dnfs) / stats.totalRaces) * 100).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Position Statistics */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<Flag className="w-5 h-5 text-red-400" />
|
||||
Position Statistics
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="p-4 rounded-xl bg-gradient-to-br from-performance-green/20 to-performance-green/5 border border-performance-green/30 text-center">
|
||||
<div className="text-4xl font-bold text-performance-green mb-1">P{stats.bestFinish}</div>
|
||||
<div className="text-xs text-gray-400 uppercase">Best Finish</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
|
||||
<div className="text-4xl font-bold text-primary-blue mb-1">
|
||||
P{(stats.avgFinish ?? 0).toFixed(1)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 uppercase">Avg Finish</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-gradient-to-br from-warning-amber/20 to-warning-amber/5 border border-warning-amber/30 text-center">
|
||||
<div className="text-4xl font-bold text-warning-amber mb-1">P{stats.worstFinish}</div>
|
||||
<div className="text-xs text-gray-400 uppercase">Worst Finish</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-gradient-to-br from-red-500/20 to-red-500/5 border border-red-500/30 text-center">
|
||||
<div className="text-4xl font-bold text-red-400 mb-1">{stats.dnfs}</div>
|
||||
<div className="text-xs text-gray-400 uppercase">DNFs</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Global Rankings */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<Trophy className="w-5 h-5 text-yellow-400" />
|
||||
Global Rankings
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="p-6 rounded-xl bg-gradient-to-br from-yellow-400/20 to-yellow-600/5 border border-yellow-400/30 text-center">
|
||||
<Trophy className="w-8 h-8 text-yellow-400 mx-auto mb-3" />
|
||||
<div className="text-3xl font-bold text-yellow-400 mb-1">#{globalRank}</div>
|
||||
<div className="text-sm text-gray-400">Global Rank</div>
|
||||
</div>
|
||||
<div className="p-6 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
|
||||
<Star className="w-8 h-8 text-primary-blue mx-auto mb-3" />
|
||||
<div className="text-3xl font-bold text-primary-blue mb-1">{stats.rating}</div>
|
||||
<div className="text-sm text-gray-400">Rating</div>
|
||||
</div>
|
||||
<div className="p-6 rounded-xl bg-gradient-to-br from-purple-400/20 to-purple-600/5 border border-purple-400/30 text-center">
|
||||
<TrendingUp className="w-8 h-8 text-purple-400 mx-auto mb-3" />
|
||||
<div className="text-3xl font-bold text-purple-400 mb-1">Top {stats.percentile}%</div>
|
||||
<div className="text-sm text-gray-400">Percentile</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'stats' && !stats && (
|
||||
<Card className="text-center py-12">
|
||||
<BarChart3 className="w-16 h-16 text-gray-600 mx-auto mb-4" />
|
||||
<p className="text-gray-400 mb-2">No statistics available yet</p>
|
||||
<p className="text-sm text-gray-500">This driver hasn't completed any races yet</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
256
apps/website/templates/DriverRankingsTemplate.tsx
Normal file
256
apps/website/templates/DriverRankingsTemplate.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Trophy, Medal, Search, ArrowLeft } from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
||||
import DriverRankingsFilter from '@/components/DriverRankingsFilter';
|
||||
import DriverTopThreePodium from '@/components/DriverTopThreePodium';
|
||||
import Image from 'next/image';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||
type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate';
|
||||
|
||||
interface DriverRankingsTemplateProps {
|
||||
drivers: DriverLeaderboardItemViewModel[];
|
||||
searchQuery: string;
|
||||
selectedSkill: 'all' | SkillLevel;
|
||||
sortBy: SortBy;
|
||||
showFilters: boolean;
|
||||
onSearchChange: (query: string) => void;
|
||||
onSkillChange: (skill: 'all' | SkillLevel) => void;
|
||||
onSortChange: (sort: SortBy) => void;
|
||||
onToggleFilters: () => void;
|
||||
onDriverClick: (id: string) => void;
|
||||
onBackToLeaderboards: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
const getMedalColor = (position: number) => {
|
||||
switch (position) {
|
||||
case 1: return 'text-yellow-400';
|
||||
case 2: return 'text-gray-300';
|
||||
case 3: return 'text-amber-600';
|
||||
default: return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getMedalBg = (position: number) => {
|
||||
switch (position) {
|
||||
case 1: return 'bg-gradient-to-br from-yellow-400/20 to-yellow-600/10 border-yellow-400/40';
|
||||
case 2: return 'bg-gradient-to-br from-gray-300/20 to-gray-400/10 border-gray-300/40';
|
||||
case 3: 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 DriverRankingsTemplate({
|
||||
drivers,
|
||||
searchQuery,
|
||||
selectedSkill,
|
||||
sortBy,
|
||||
showFilters,
|
||||
onSearchChange,
|
||||
onSkillChange,
|
||||
onSortChange,
|
||||
onToggleFilters,
|
||||
onDriverClick,
|
||||
onBackToLeaderboards,
|
||||
}: DriverRankingsTemplateProps) {
|
||||
// Filter drivers
|
||||
const filteredDrivers = drivers.filter((driver) => {
|
||||
const matchesSearch = driver.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
driver.nationality.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesSkill = selectedSkill === 'all' || driver.skillLevel === selectedSkill;
|
||||
return matchesSearch && matchesSkill;
|
||||
});
|
||||
|
||||
// Sort drivers
|
||||
const sortedDrivers = [...filteredDrivers].sort((a, b) => {
|
||||
const rankA = Number.isFinite(a.rank) && a.rank > 0 ? a.rank : Number.POSITIVE_INFINITY;
|
||||
const rankB = Number.isFinite(b.rank) && b.rank > 0 ? b.rank : Number.POSITIVE_INFINITY;
|
||||
|
||||
switch (sortBy) {
|
||||
case 'rank':
|
||||
return rankA - rankB || b.rating - a.rating || a.name.localeCompare(b.name);
|
||||
case 'rating':
|
||||
return b.rating - a.rating;
|
||||
case 'wins':
|
||||
return b.wins - a.wins;
|
||||
case 'podiums':
|
||||
return b.podiums - a.podiums;
|
||||
case 'winRate': {
|
||||
const aRate = a.racesCompleted > 0 ? a.wins / a.racesCompleted : 0;
|
||||
const bRate = b.racesCompleted > 0 ? b.wins / b.racesCompleted : 0;
|
||||
return bRate - aRate;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 pb-12">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBackToLeaderboards}
|
||||
className="flex items-center gap-2 mb-6"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Leaderboards
|
||||
</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-primary-blue/20 to-primary-blue/5 border border-primary-blue/20">
|
||||
<Trophy className="w-7 h-7 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<Heading level={1} className="text-3xl lg:text-4xl">
|
||||
Driver Leaderboard
|
||||
</Heading>
|
||||
<p className="text-gray-400">Full rankings of all drivers by performance metrics</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top 3 Podium */}
|
||||
{!searchQuery && sortBy === 'rank' && <DriverTopThreePodium drivers={sortedDrivers} onDriverClick={onDriverClick} />}
|
||||
|
||||
{/* Filters */}
|
||||
<DriverRankingsFilter
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={onSearchChange}
|
||||
selectedSkill={selectedSkill}
|
||||
onSkillChange={onSkillChange}
|
||||
sortBy={sortBy}
|
||||
onSortChange={onSortChange}
|
||||
showFilters={showFilters}
|
||||
onToggleFilters={onToggleFilters}
|
||||
/>
|
||||
|
||||
{/* 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-5 lg:col-span-4">Driver</div>
|
||||
<div className="col-span-2 text-center hidden md:block">Races</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-1 text-center hidden lg:block">Podiums</div>
|
||||
<div className="col-span-2 text-center">Win Rate</div>
|
||||
</div>
|
||||
|
||||
{/* Table Body */}
|
||||
<div className="divide-y divide-charcoal-outline/50">
|
||||
{sortedDrivers.map((driver, index) => {
|
||||
const winRate = driver.racesCompleted > 0 ? ((driver.wins / driver.racesCompleted) * 100).toFixed(1) : '0.0';
|
||||
const position = index + 1;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={driver.id}
|
||||
type="button"
|
||||
onClick={() => onDriverClick(driver.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(position)} ${getMedalColor(position)}`}>
|
||||
{position <= 3 ? <Medal className="w-4 h-4" /> : position}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Driver Info */}
|
||||
<div className="col-span-5 lg:col-span-4 flex items-center gap-3">
|
||||
<div className="relative w-10 h-10 rounded-full overflow-hidden border-2 border-charcoal-outline">
|
||||
<Image src={driver.avatarUrl} alt={driver.name} fill className="object-cover" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-white font-semibold truncate group-hover:text-primary-blue transition-colors">
|
||||
{driver.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
{driver.nationality}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{driver.skillLevel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Races */}
|
||||
<div className="col-span-2 items-center justify-center hidden md:flex">
|
||||
<span className="text-gray-400">{driver.racesCompleted}</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-primary-blue' : 'text-white'}`}>
|
||||
{driver.rating.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-primary-blue' : 'text-performance-green'}`}>
|
||||
{driver.wins}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Podiums */}
|
||||
<div className="col-span-1 items-center justify-center hidden lg:flex">
|
||||
<span className={`font-mono font-semibold ${sortBy === 'podiums' ? 'text-primary-blue' : 'text-warning-amber'}`}>
|
||||
{driver.podiums}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Win Rate */}
|
||||
<div className="col-span-2 flex items-center justify-center">
|
||||
<span className={`font-mono font-semibold ${sortBy === 'winRate' ? 'text-primary-blue' : 'text-white'}`}>
|
||||
{winRate}%
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{sortedDrivers.length === 0 && (
|
||||
<div className="py-16 text-center">
|
||||
<Search className="w-12 h-12 text-gray-600 mx-auto mb-4" />
|
||||
<p className="text-gray-400 mb-2">No drivers found</p>
|
||||
<p className="text-sm text-gray-500">Try adjusting your filters or search query</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
onSearchChange('');
|
||||
onSkillChange('all');
|
||||
}}
|
||||
className="mt-4"
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
apps/website/templates/DriversTemplate.tsx
Normal file
202
apps/website/templates/DriversTemplate.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Trophy,
|
||||
Users,
|
||||
Search,
|
||||
Crown,
|
||||
} from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import { FeaturedDriverCard } from '@/components/drivers/FeaturedDriverCard';
|
||||
import { SkillDistribution } from '@/components/drivers/SkillDistribution';
|
||||
import { CategoryDistribution } from '@/components/drivers/CategoryDistribution';
|
||||
import { LeaderboardPreview } from '@/components/drivers/LeaderboardPreview';
|
||||
import { RecentActivity } from '@/components/drivers/RecentActivity';
|
||||
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
||||
|
||||
interface DriversTemplateProps {
|
||||
drivers: DriverLeaderboardItemViewModel[];
|
||||
totalRaces: number;
|
||||
totalWins: number;
|
||||
activeCount: number;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function DriversTemplate({
|
||||
drivers,
|
||||
totalRaces,
|
||||
totalWins,
|
||||
activeCount,
|
||||
isLoading = false
|
||||
}: DriversTemplateProps) {
|
||||
const router = useRouter();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const handleDriverClick = (driverId: string) => {
|
||||
router.push(`/drivers/${driverId}`);
|
||||
};
|
||||
|
||||
// Filter by search
|
||||
const filteredDrivers = drivers.filter((driver) => {
|
||||
if (!searchQuery) return true;
|
||||
return (
|
||||
driver.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
driver.nationality.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
// Featured drivers (top 4)
|
||||
const featuredDrivers = filteredDrivers.slice(0, 4);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-gray-400">Loading drivers...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 pb-12">
|
||||
{/* Hero Section */}
|
||||
<div className="relative mb-10 py-10 px-8 rounded-2xl bg-gradient-to-br from-primary-blue/20 via-iron-gray/80 to-deep-graphite border border-primary-blue/30 overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-primary-blue/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 left-0 w-64 h-64 bg-yellow-400/5 rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 right-1/4 w-48 h-48 bg-performance-green/5 rounded-full blur-2xl" />
|
||||
|
||||
<div className="relative z-10 flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8">
|
||||
<div className="max-w-2xl">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/20">
|
||||
<Users className="w-6 h-6 text-primary-blue" />
|
||||
</div>
|
||||
<Heading level={1} className="text-3xl lg:text-4xl">
|
||||
Drivers
|
||||
</Heading>
|
||||
</div>
|
||||
<p className="text-gray-400 text-lg leading-relaxed mb-6">
|
||||
Meet the racers who make every lap count. From rookies to champions, track their journey and see who's dominating the grid.
|
||||
</p>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-primary-blue" />
|
||||
<span className="text-sm text-gray-400">
|
||||
<span className="text-white font-semibold">{drivers.length}</span> drivers
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-performance-green animate-pulse" />
|
||||
<span className="text-sm text-gray-400">
|
||||
<span className="text-white font-semibold">{activeCount}</span> active
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-400" />
|
||||
<span className="text-sm text-gray-400">
|
||||
<span className="text-white font-semibold">{totalWins.toLocaleString()}</span> total wins
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-neon-aqua" />
|
||||
<span className="text-sm text-gray-400">
|
||||
<span className="text-white font-semibold">{totalRaces.toLocaleString()}</span> races
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => router.push('/leaderboards/drivers')}
|
||||
className="flex items-center gap-2 px-6 py-3"
|
||||
>
|
||||
<Trophy className="w-5 h-5" />
|
||||
View Leaderboard
|
||||
</Button>
|
||||
<p className="text-xs text-gray-500 text-center">See full driver rankings</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-8">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search drivers by name or nationality..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Featured Drivers */}
|
||||
{!searchQuery && (
|
||||
<div className="mb-10">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-yellow-400/10 border border-yellow-400/20">
|
||||
<Crown className="w-5 h-5 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Featured Drivers</h2>
|
||||
<p className="text-xs text-gray-500">Top performers on the grid</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{featuredDrivers.map((driver, index) => (
|
||||
<FeaturedDriverCard
|
||||
key={driver.id}
|
||||
driver={driver}
|
||||
position={index + 1}
|
||||
onClick={() => handleDriverClick(driver.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Drivers */}
|
||||
{!searchQuery && <RecentActivity drivers={drivers} onDriverClick={handleDriverClick} />}
|
||||
|
||||
{/* Skill Distribution */}
|
||||
{!searchQuery && <SkillDistribution drivers={drivers} />}
|
||||
|
||||
{/* Category Distribution */}
|
||||
{!searchQuery && <CategoryDistribution drivers={drivers} />}
|
||||
|
||||
{/* Leaderboard Preview */}
|
||||
<LeaderboardPreview drivers={filteredDrivers} onDriverClick={handleDriverClick} />
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredDrivers.length === 0 && (
|
||||
<Card className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Search className="w-10 h-10 text-gray-600" />
|
||||
<p className="text-gray-400">No drivers found matching "{searchQuery}"</p>
|
||||
<Button variant="secondary" onClick={() => setSearchQuery('')}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
apps/website/templates/LeaderboardsTemplate.tsx
Normal file
92
apps/website/templates/LeaderboardsTemplate.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Trophy, Users, Award } from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import DriverLeaderboardPreview from '@/components/leaderboards/DriverLeaderboardPreview';
|
||||
import TeamLeaderboardPreview from '@/components/leaderboards/TeamLeaderboardPreview';
|
||||
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
interface LeaderboardsTemplateProps {
|
||||
drivers: DriverLeaderboardItemViewModel[];
|
||||
teams: TeamSummaryViewModel[];
|
||||
onDriverClick: (driverId: string) => void;
|
||||
onTeamClick: (teamId: string) => void;
|
||||
onNavigateToDrivers: () => void;
|
||||
onNavigateToTeams: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN TEMPLATE COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export default function LeaderboardsTemplate({
|
||||
drivers,
|
||||
teams,
|
||||
onDriverClick,
|
||||
onTeamClick,
|
||||
onNavigateToDrivers,
|
||||
onNavigateToTeams,
|
||||
}: LeaderboardsTemplateProps) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 pb-12">
|
||||
{/* Hero Section */}
|
||||
<div className="relative mb-10 py-10 px-8 rounded-2xl bg-gradient-to-br from-yellow-600/20 via-iron-gray/80 to-deep-graphite border border-yellow-500/20 overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-yellow-400/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 left-0 w-64 h-64 bg-amber-600/5 rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 right-1/4 w-48 h-48 bg-purple-500/5 rounded-full blur-2xl" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<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">
|
||||
Leaderboards
|
||||
</Heading>
|
||||
<p className="text-gray-400">Where champions rise and legends are made</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-400 text-lg leading-relaxed max-w-2xl mb-6">
|
||||
Track the best drivers and teams across all competitions. Every race counts. Every position matters. Who will claim the throne?
|
||||
</p>
|
||||
|
||||
{/* Quick Nav */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onNavigateToDrivers}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Trophy className="w-4 h-4 text-primary-blue" />
|
||||
Driver Rankings
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onNavigateToTeams}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Users className="w-4 h-4 text-purple-400" />
|
||||
Team Rankings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leaderboard Grids */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<DriverLeaderboardPreview drivers={drivers} onDriverClick={onDriverClick} />
|
||||
<TeamLeaderboardPreview teams={teams} onTeamClick={onTeamClick} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
530
apps/website/templates/LeagueDetailTemplate.tsx
Normal file
530
apps/website/templates/LeagueDetailTemplate.tsx
Normal file
@@ -0,0 +1,530 @@
|
||||
'use client';
|
||||
|
||||
import DriverIdentity from '@/components/drivers/DriverIdentity';
|
||||
import JoinLeagueButton from '@/components/leagues/JoinLeagueButton';
|
||||
import LeagueActivityFeed from '@/components/leagues/LeagueActivityFeed';
|
||||
import SponsorInsightsCard, {
|
||||
MetricBuilders,
|
||||
SlotTemplates,
|
||||
type SponsorMetric,
|
||||
} from '@/components/sponsors/SponsorInsightsCard';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { LeagueRoleDisplay } from '@/lib/display-objects/LeagueRoleDisplay';
|
||||
import type { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
|
||||
import type { RaceViewModel } from '@/lib/view-models/RaceViewModel';
|
||||
import type { DriverSummary } from '@/lib/view-models/LeagueDetailPageViewModel';
|
||||
import { Calendar, ExternalLink, Star, Trophy, Users } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
interface LeagueDetailTemplateProps {
|
||||
viewModel: LeagueDetailPageViewModel;
|
||||
leagueId: string;
|
||||
isSponsor: boolean;
|
||||
membership: { role: string } | null;
|
||||
currentDriverId: string | null;
|
||||
onMembershipChange: () => void;
|
||||
onEndRaceModalOpen: (raceId: string) => void;
|
||||
onLiveRaceClick: (raceId: string) => void;
|
||||
onBackToLeagues: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
interface LiveRaceCardProps {
|
||||
races: RaceViewModel[];
|
||||
membership: { role: string } | null;
|
||||
onLiveRaceClick: (raceId: string) => void;
|
||||
onEndRaceModalOpen: (raceId: string) => void;
|
||||
}
|
||||
|
||||
interface LeagueInfoCardProps {
|
||||
viewModel: LeagueDetailPageViewModel;
|
||||
}
|
||||
|
||||
interface SponsorsSectionProps {
|
||||
sponsors: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
tier: 'main' | 'secondary';
|
||||
logoUrl?: string;
|
||||
tagline?: string;
|
||||
websiteUrl?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ManagementSectionProps {
|
||||
ownerSummary?: DriverSummary | null;
|
||||
adminSummaries: DriverSummary[];
|
||||
stewardSummaries: DriverSummary[];
|
||||
leagueId: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LIVE RACE CARD COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
function LiveRaceCard({ races, membership, onLiveRaceClick, onEndRaceModalOpen }: LiveRaceCardProps) {
|
||||
if (races.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card className="border-2 border-performance-green/50 bg-gradient-to-r from-performance-green/10 to-performance-green/5 mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-3 h-3 bg-performance-green rounded-full animate-pulse"></div>
|
||||
<h2 className="text-xl font-bold text-white">🏁 Live Race in Progress</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{races.map((race) => (
|
||||
<div
|
||||
key={race.id}
|
||||
className="p-4 rounded-lg bg-deep-graphite border border-performance-green/30"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="px-3 py-1 bg-performance-green/20 border border-performance-green/40 rounded-full">
|
||||
<span className="text-sm font-semibold text-performance-green">LIVE</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{race.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => onLiveRaceClick(race.id)}
|
||||
className="bg-performance-green hover:bg-performance-green/80 text-white"
|
||||
>
|
||||
View Live Race
|
||||
</Button>
|
||||
{membership?.role === 'admin' && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onEndRaceModalOpen(race.id)}
|
||||
className="border-performance-green/50 text-performance-green hover:bg-performance-green/10"
|
||||
>
|
||||
End Race & Process Results
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm text-gray-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>Started {new Date(race.date).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{race.registeredCount && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>{race.registeredCount} drivers registered</span>
|
||||
</div>
|
||||
)}
|
||||
{race.strengthOfField && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4" />
|
||||
<span>SOF: {race.strengthOfField}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LEAGUE INFO CARD COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
function LeagueInfoCard({ viewModel }: LeagueInfoCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">About</h3>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
|
||||
<div className="text-xl font-bold text-white">{viewModel.memberships.length}</div>
|
||||
<div className="text-xs text-gray-500">Members</div>
|
||||
</div>
|
||||
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
|
||||
<div className="text-xl font-bold text-white">{viewModel.completedRacesCount}</div>
|
||||
<div className="text-xs text-gray-500">Races</div>
|
||||
</div>
|
||||
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
|
||||
<div className="text-xl font-bold text-warning-amber">{viewModel.averageSOF ?? '—'}</div>
|
||||
<div className="text-xs text-gray-500">Avg SOF</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-500">Structure</span>
|
||||
<span className="text-white">Solo • {viewModel.settings.maxDrivers ?? 32} max</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-500">Scoring</span>
|
||||
<span className="text-white">{viewModel.scoringConfig?.scoringPresetName ?? 'Standard'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-gray-500">Created</span>
|
||||
<span className="text-white">
|
||||
{new Date(viewModel.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewModel.socialLinks && (
|
||||
<div className="mt-4 pt-4 border-t border-charcoal-outline">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{viewModel.socialLinks.discordUrl && (
|
||||
<a
|
||||
href={viewModel.socialLinks.discordUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-primary-blue/40 bg-primary-blue/10 px-2 py-1 text-xs text-primary-blue hover:bg-primary-blue/20 transition-colors"
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
)}
|
||||
{viewModel.socialLinks.youtubeUrl && (
|
||||
<a
|
||||
href={viewModel.socialLinks.youtubeUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-red-500/40 bg-red-500/10 px-2 py-1 text-xs text-red-400 hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
YouTube
|
||||
</a>
|
||||
)}
|
||||
{viewModel.socialLinks.websiteUrl && (
|
||||
<a
|
||||
href={viewModel.socialLinks.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-charcoal-outline bg-iron-gray/70 px-2 py-1 text-xs text-gray-100 hover:bg-iron-gray transition-colors"
|
||||
>
|
||||
Website
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SPONSORS SECTION COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
function SponsorsSection({ sponsors }: SponsorsSectionProps) {
|
||||
if (sponsors.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">
|
||||
{sponsors.find(s => s.tier === 'main') ? 'Presented by' : 'Sponsors'}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{/* Main Sponsor - Featured prominently */}
|
||||
{sponsors.filter(s => s.tier === 'main').map(sponsor => (
|
||||
<div
|
||||
key={sponsor.id}
|
||||
className="p-3 rounded-lg bg-gradient-to-r from-yellow-500/10 to-transparent border border-yellow-500/30"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{sponsor.logoUrl ? (
|
||||
<div className="w-12 h-12 rounded-lg bg-white flex items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={sponsor.logoUrl}
|
||||
alt={sponsor.name}
|
||||
className="w-10 h-10 object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg bg-yellow-500/20 flex items-center justify-center">
|
||||
<Trophy className="w-6 h-6 text-yellow-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-white truncate">{sponsor.name}</span>
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
|
||||
Main
|
||||
</span>
|
||||
</div>
|
||||
{sponsor.tagline && (
|
||||
<p className="text-xs text-gray-400 truncate mt-0.5">{sponsor.tagline}</p>
|
||||
)}
|
||||
</div>
|
||||
{sponsor.websiteUrl && (
|
||||
<a
|
||||
href={sponsor.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-1.5 rounded-lg bg-iron-gray hover:bg-charcoal-outline transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 text-gray-400" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Secondary Sponsors - Smaller display */}
|
||||
{sponsors.filter(s => s.tier === 'secondary').length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{sponsors.filter(s => s.tier === 'secondary').map(sponsor => (
|
||||
<div
|
||||
key={sponsor.id}
|
||||
className="p-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{sponsor.logoUrl ? (
|
||||
<div className="w-8 h-8 rounded bg-white flex items-center justify-center overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
src={sponsor.logoUrl}
|
||||
alt={sponsor.name}
|
||||
className="w-6 h-6 object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded bg-purple-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Star className="w-4 h-4 text-purple-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-white truncate block">{sponsor.name}</span>
|
||||
</div>
|
||||
{sponsor.websiteUrl && (
|
||||
<a
|
||||
href={sponsor.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-1 rounded hover:bg-charcoal-outline transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MANAGEMENT SECTION COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
function ManagementSection({ ownerSummary, adminSummaries, stewardSummaries, leagueId }: ManagementSectionProps) {
|
||||
if (!ownerSummary && adminSummaries.length === 0 && stewardSummaries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Management</h3>
|
||||
<div className="space-y-2">
|
||||
{ownerSummary && (() => {
|
||||
const summary = ownerSummary;
|
||||
const roleDisplay = LeagueRoleDisplay.getLeagueRoleDisplay('owner');
|
||||
const meta = summary.rating !== null
|
||||
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<DriverIdentity
|
||||
driver={summary.driver}
|
||||
href={`/drivers/${summary.driver.id}?from=league-management&leagueId=${leagueId}`}
|
||||
meta={meta}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
|
||||
{roleDisplay.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{adminSummaries.map((summary) => {
|
||||
const roleDisplay = LeagueRoleDisplay.getLeagueRoleDisplay('admin');
|
||||
const meta = summary.rating !== null
|
||||
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={summary.driver.id} className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<DriverIdentity
|
||||
driver={summary.driver}
|
||||
href={`/drivers/${summary.driver.id}?from=league-management&leagueId=${leagueId}`}
|
||||
meta={meta}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
|
||||
{roleDisplay.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{stewardSummaries.map((summary) => {
|
||||
const roleDisplay = LeagueRoleDisplay.getLeagueRoleDisplay('steward');
|
||||
const meta = summary.rating !== null
|
||||
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={summary.driver.id} className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<DriverIdentity
|
||||
driver={summary.driver}
|
||||
href={`/drivers/${summary.driver.id}?from=league-management&leagueId=${leagueId}`}
|
||||
meta={meta}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
|
||||
{roleDisplay.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN TEMPLATE COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function LeagueDetailTemplate({
|
||||
viewModel,
|
||||
leagueId,
|
||||
isSponsor,
|
||||
membership,
|
||||
currentDriverId,
|
||||
onMembershipChange,
|
||||
onEndRaceModalOpen,
|
||||
onLiveRaceClick,
|
||||
onBackToLeagues,
|
||||
children,
|
||||
}: LeagueDetailTemplateProps) {
|
||||
// Build metrics for SponsorInsightsCard
|
||||
const leagueMetrics: SponsorMetric[] = [
|
||||
MetricBuilders.views(viewModel.sponsorInsights.avgViewsPerRace, 'Avg Views/Race'),
|
||||
MetricBuilders.engagement(viewModel.sponsorInsights.engagementRate),
|
||||
MetricBuilders.reach(viewModel.sponsorInsights.estimatedReach),
|
||||
MetricBuilders.sof(viewModel.averageSOF ?? '—'),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sponsor Insights Card - Only shown to sponsors, at top of page */}
|
||||
{isSponsor && viewModel && (
|
||||
<SponsorInsightsCard
|
||||
entityType="league"
|
||||
entityId={leagueId}
|
||||
entityName={viewModel.name}
|
||||
tier={viewModel.sponsorInsights.tier}
|
||||
metrics={leagueMetrics}
|
||||
slots={SlotTemplates.league(
|
||||
viewModel.sponsorInsights.mainSponsorAvailable,
|
||||
viewModel.sponsorInsights.secondarySlotsAvailable,
|
||||
viewModel.sponsorInsights.mainSponsorPrice,
|
||||
viewModel.sponsorInsights.secondaryPrice
|
||||
)}
|
||||
trustScore={viewModel.sponsorInsights.trustScore}
|
||||
discordMembers={viewModel.sponsorInsights.discordMembers}
|
||||
monthlyActivity={viewModel.sponsorInsights.monthlyActivity}
|
||||
additionalStats={{
|
||||
label: 'League Stats',
|
||||
items: [
|
||||
{ label: 'Total Races', value: viewModel.completedRacesCount },
|
||||
{ label: 'Active Members', value: viewModel.memberships.length },
|
||||
{ label: 'Total Impressions', value: viewModel.sponsorInsights.totalImpressions },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Live Race Card - Prominently show running races */}
|
||||
{viewModel && viewModel.runningRaces.length > 0 && (
|
||||
<LiveRaceCard
|
||||
races={viewModel.runningRaces}
|
||||
membership={membership}
|
||||
onLiveRaceClick={onLiveRaceClick}
|
||||
onEndRaceModalOpen={onEndRaceModalOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Action Card */}
|
||||
{!membership && !isSponsor && (
|
||||
<Card className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Join This League</h3>
|
||||
<p className="text-gray-400 text-sm">Become a member to participate in races and track your progress</p>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<JoinLeagueButton
|
||||
leagueId={leagueId}
|
||||
onMembershipChange={onMembershipChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* League Overview - Activity Center with Info Sidebar */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Center - Activity Feed */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-6">Recent Activity</h2>
|
||||
<LeagueActivityFeed leagueId={leagueId} limit={20} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - League Info */}
|
||||
<div className="space-y-6">
|
||||
{/* League Info - Combined */}
|
||||
<LeagueInfoCard viewModel={viewModel} />
|
||||
|
||||
{/* Sponsors Section - Show sponsor logos */}
|
||||
{viewModel.sponsors.length > 0 && (
|
||||
<SponsorsSection sponsors={viewModel.sponsors} />
|
||||
)}
|
||||
|
||||
{/* Management */}
|
||||
<ManagementSection
|
||||
ownerSummary={viewModel.ownerSummary}
|
||||
adminSummaries={viewModel.adminSummaries}
|
||||
stewardSummaries={viewModel.stewardSummaries}
|
||||
leagueId={leagueId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Children (for modals, etc.) */}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
251
apps/website/templates/LeagueRulebookTemplate.tsx
Normal file
251
apps/website/templates/LeagueRulebookTemplate.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import PointsTable from '@/components/leagues/PointsTable';
|
||||
import type { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
type RulebookSection = 'scoring' | 'conduct' | 'protests' | 'penalties';
|
||||
|
||||
interface LeagueRulebookTemplateProps {
|
||||
viewModel: LeagueDetailPageViewModel;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN TEMPLATE COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function LeagueRulebookTemplate({
|
||||
viewModel,
|
||||
loading = false,
|
||||
}: LeagueRulebookTemplateProps) {
|
||||
const [activeSection, setActiveSection] = useState<RulebookSection>('scoring');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12 text-gray-400">Loading rulebook...</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!viewModel || !viewModel.scoringConfig) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12 text-gray-400">Unable to load rulebook</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const primaryChampionship = viewModel.scoringConfig.championships.find(c => c.type === 'driver') ?? viewModel.scoringConfig.championships[0];
|
||||
const positionPoints = primaryChampionship?.pointsPreview
|
||||
.filter(p => (p as any).sessionType === primaryChampionship.sessionTypes[0])
|
||||
.map(p => ({ position: Number((p as any).position), points: Number((p as any).points) }))
|
||||
.sort((a, b) => a.position - b.position) || [];
|
||||
|
||||
const sections: { id: RulebookSection; label: string }[] = [
|
||||
{ id: 'scoring', label: 'Scoring' },
|
||||
{ id: 'conduct', label: 'Conduct' },
|
||||
{ id: 'protests', label: 'Protests' },
|
||||
{ id: 'penalties', label: 'Penalties' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Rulebook</h1>
|
||||
<p className="text-sm text-gray-400 mt-1">Official rules and regulations</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-blue/10 border border-primary-blue/20">
|
||||
<span className="text-sm font-medium text-primary-blue">{viewModel.scoringConfig.scoringPresetName || 'Custom Rules'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex gap-1 p-1 bg-deep-graphite rounded-lg border border-charcoal-outline">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeSection === section.id
|
||||
? 'bg-iron-gray text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-iron-gray/50'
|
||||
}`}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content Sections */}
|
||||
{activeSection === 'scoring' && (
|
||||
<div className="space-y-6">
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Platform</p>
|
||||
<p className="text-lg font-semibold text-white">{viewModel.scoringConfig.gameName}</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Championships</p>
|
||||
<p className="text-lg font-semibold text-white">{viewModel.scoringConfig.championships.length}</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Sessions Scored</p>
|
||||
<p className="text-lg font-semibold text-white capitalize">
|
||||
{primaryChampionship?.sessionTypes.join(', ') || 'Main'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Drop Policy</p>
|
||||
<p className="text-lg font-semibold text-white truncate" title={viewModel.scoringConfig.dropPolicySummary}>
|
||||
{viewModel.scoringConfig.dropPolicySummary.includes('All') ? 'None' : 'Active'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Points Table */}
|
||||
<PointsTable points={positionPoints} />
|
||||
|
||||
{/* Bonus Points */}
|
||||
{primaryChampionship?.bonusSummary && primaryChampionship.bonusSummary.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Bonus Points</h2>
|
||||
<div className="space-y-2">
|
||||
{primaryChampionship.bonusSummary.map((bonus, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-4 p-3 bg-deep-graphite rounded-lg border border-charcoal-outline"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-performance-green/10 border border-performance-green/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-performance-green text-sm font-bold">+</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">{bonus}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Drop Policy */}
|
||||
{!viewModel.scoringConfig.dropPolicySummary.includes('All results count') && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Drop Policy</h2>
|
||||
<p className="text-sm text-gray-300">{viewModel.scoringConfig.dropPolicySummary}</p>
|
||||
<p className="text-xs text-gray-500 mt-3">
|
||||
Drop rules are applied automatically when calculating championship standings.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'conduct' && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Driver Conduct</h2>
|
||||
<div className="space-y-4 text-sm text-gray-300">
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">1. Respect</h3>
|
||||
<p>All drivers must treat each other with respect. Abusive language, harassment, or unsportsmanlike behavior will not be tolerated.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">2. Clean Racing</h3>
|
||||
<p>Intentional wrecking, blocking, or dangerous driving is prohibited. Leave space for other drivers and race cleanly.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">3. Track Limits</h3>
|
||||
<p>Drivers must stay within track limits. Gaining a lasting advantage by exceeding track limits may result in penalties.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">4. Blue Flags</h3>
|
||||
<p>Lapped cars must yield to faster traffic within a reasonable time. Failure to do so may result in penalties.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">5. Communication</h3>
|
||||
<p>Drivers are expected to communicate respectfully in voice and text chat during sessions.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeSection === 'protests' && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Protest Process</h2>
|
||||
<div className="space-y-4 text-sm text-gray-300">
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">Filing a Protest</h3>
|
||||
<p>Protests can be filed within 48 hours of the race conclusion. Include the lap number, drivers involved, and a clear description of the incident.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">Evidence</h3>
|
||||
<p>Video evidence is highly recommended but not required. Stewards will review available replay data.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">Review Process</h3>
|
||||
<p>League stewards will review protests and make decisions within 72 hours. Decisions are final unless new evidence is presented.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">Outcomes</h3>
|
||||
<p>Protests may result in no action, warnings, time penalties, position penalties, or points deductions depending on severity.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeSection === 'penalties' && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Penalty Guidelines</h2>
|
||||
<div className="space-y-4 text-sm text-gray-300">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-charcoal-outline">
|
||||
<th className="text-left py-2 font-medium text-gray-400">Infraction</th>
|
||||
<th className="text-left py-2 font-medium text-gray-400">Typical Penalty</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-charcoal-outline/50">
|
||||
<tr>
|
||||
<td className="py-3">Causing avoidable contact</td>
|
||||
<td className="py-3 text-warning-amber">5-10 second time penalty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Unsafe rejoin</td>
|
||||
<td className="py-3 text-warning-amber">5 second time penalty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Blocking</td>
|
||||
<td className="py-3 text-warning-amber">Warning or 3 second penalty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Repeated track limit violations</td>
|
||||
<td className="py-3 text-warning-amber">5 second penalty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Intentional wrecking</td>
|
||||
<td className="py-3 text-red-400">Disqualification</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Unsportsmanlike conduct</td>
|
||||
<td className="py-3 text-red-400">Points deduction or ban</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-4">
|
||||
Penalties are applied at steward discretion based on incident severity and driver history.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
apps/website/templates/LeagueScheduleTemplate.tsx
Normal file
39
apps/website/templates/LeagueScheduleTemplate.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import LeagueSchedule from '@/components/leagues/LeagueSchedule';
|
||||
import Card from '@/components/ui/Card';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
interface LeagueScheduleTemplateProps {
|
||||
leagueId: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN TEMPLATE COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function LeagueScheduleTemplate({
|
||||
leagueId,
|
||||
loading = false,
|
||||
}: LeagueScheduleTemplateProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center text-gray-400">
|
||||
Loading schedule...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Schedule</h2>
|
||||
<LeagueSchedule leagueId={leagueId} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
apps/website/templates/LeagueStandingsTemplate.tsx
Normal file
90
apps/website/templates/LeagueStandingsTemplate.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import StandingsTable from '@/components/leagues/StandingsTable';
|
||||
import LeagueChampionshipStats from '@/components/leagues/LeagueChampionshipStats';
|
||||
import Card from '@/components/ui/Card';
|
||||
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
|
||||
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
import type { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
interface LeagueStandingsTemplateProps {
|
||||
standings: StandingEntryViewModel[];
|
||||
drivers: DriverViewModel[];
|
||||
memberships: LeagueMembership[];
|
||||
leagueId: string;
|
||||
currentDriverId: string | null;
|
||||
isAdmin: boolean;
|
||||
onRemoveMember: (driverId: string) => void;
|
||||
onUpdateRole: (driverId: string, newRole: string) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN TEMPLATE COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function LeagueStandingsTemplate({
|
||||
standings,
|
||||
drivers,
|
||||
memberships,
|
||||
leagueId,
|
||||
currentDriverId,
|
||||
isAdmin,
|
||||
onRemoveMember,
|
||||
onUpdateRole,
|
||||
loading = false,
|
||||
}: LeagueStandingsTemplateProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center text-gray-400">
|
||||
Loading standings...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Championship Stats */}
|
||||
<LeagueChampionshipStats standings={standings} drivers={drivers} />
|
||||
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Championship Standings</h2>
|
||||
<StandingsTable
|
||||
standings={standings.map((s) => ({
|
||||
leagueId,
|
||||
driverId: s.driverId,
|
||||
position: s.position,
|
||||
totalPoints: s.points,
|
||||
racesFinished: s.races,
|
||||
racesStarted: s.races,
|
||||
avgFinish: null,
|
||||
penaltyPoints: 0,
|
||||
bonusPoints: 0,
|
||||
}) satisfies {
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
totalPoints: number;
|
||||
racesFinished: number;
|
||||
racesStarted: number;
|
||||
avgFinish: number | null;
|
||||
penaltyPoints: number;
|
||||
bonusPoints: number;
|
||||
teamName?: string;
|
||||
})}
|
||||
drivers={drivers}
|
||||
leagueId={leagueId}
|
||||
memberships={memberships}
|
||||
currentDriverId={currentDriverId ?? undefined}
|
||||
isAdmin={isAdmin}
|
||||
onRemoveMember={onRemoveMember}
|
||||
onUpdateRole={onUpdateRole}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
670
apps/website/templates/LeaguesTemplate.tsx
Normal file
670
apps/website/templates/LeaguesTemplate.tsx
Normal file
@@ -0,0 +1,670 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Trophy,
|
||||
Users,
|
||||
Globe,
|
||||
Award,
|
||||
Search,
|
||||
Plus,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
Flag,
|
||||
Filter,
|
||||
Flame,
|
||||
Clock,
|
||||
Target,
|
||||
Timer,
|
||||
} from 'lucide-react';
|
||||
import LeagueCard from '@/components/leagues/LeagueCard';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
type CategoryId =
|
||||
| 'all'
|
||||
| 'driver'
|
||||
| 'team'
|
||||
| 'nations'
|
||||
| 'trophy'
|
||||
| 'new'
|
||||
| 'popular'
|
||||
| 'iracing'
|
||||
| 'acc'
|
||||
| 'f1'
|
||||
| 'endurance'
|
||||
| 'sprint'
|
||||
| 'openSlots';
|
||||
|
||||
interface Category {
|
||||
id: CategoryId;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
description: string;
|
||||
filter: (league: LeagueSummaryViewModel) => boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface LeagueSliderProps {
|
||||
title: string;
|
||||
icon: React.ElementType;
|
||||
description: string;
|
||||
leagues: LeagueSummaryViewModel[];
|
||||
onLeagueClick: (id: string) => void;
|
||||
autoScroll?: boolean;
|
||||
iconColor?: string;
|
||||
scrollSpeedMultiplier?: number;
|
||||
scrollDirection?: 'left' | 'right';
|
||||
}
|
||||
|
||||
interface LeaguesTemplateProps {
|
||||
leagues: LeagueSummaryViewModel[];
|
||||
loading?: boolean;
|
||||
onLeagueClick: (id: string) => void;
|
||||
onCreateLeagueClick: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CATEGORIES
|
||||
// ============================================================================
|
||||
|
||||
const CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 'all',
|
||||
label: 'All',
|
||||
icon: Globe,
|
||||
description: 'Browse all available leagues',
|
||||
filter: () => true,
|
||||
},
|
||||
{
|
||||
id: 'popular',
|
||||
label: 'Popular',
|
||||
icon: Flame,
|
||||
description: 'Most active leagues right now',
|
||||
filter: (league) => {
|
||||
const fillRate = (league.usedDriverSlots ?? 0) / (league.maxDrivers ?? 1);
|
||||
return fillRate > 0.7;
|
||||
},
|
||||
color: 'text-orange-400',
|
||||
},
|
||||
{
|
||||
id: 'new',
|
||||
label: 'New',
|
||||
icon: Sparkles,
|
||||
description: 'Fresh leagues looking for members',
|
||||
filter: (league) => {
|
||||
const oneWeekAgo = new Date();
|
||||
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
|
||||
return new Date(league.createdAt) > oneWeekAgo;
|
||||
},
|
||||
color: 'text-performance-green',
|
||||
},
|
||||
{
|
||||
id: 'openSlots',
|
||||
label: 'Open Slots',
|
||||
icon: Target,
|
||||
description: 'Leagues with available spots',
|
||||
filter: (league) => {
|
||||
// Check for team slots if it's a team league
|
||||
if (league.maxTeams && league.maxTeams > 0) {
|
||||
const usedTeams = league.usedTeamSlots ?? 0;
|
||||
return usedTeams < league.maxTeams;
|
||||
}
|
||||
// Otherwise check driver slots
|
||||
const used = league.usedDriverSlots ?? 0;
|
||||
const max = league.maxDrivers ?? 0;
|
||||
return max > 0 && used < max;
|
||||
},
|
||||
color: 'text-neon-aqua',
|
||||
},
|
||||
{
|
||||
id: 'driver',
|
||||
label: 'Driver',
|
||||
icon: Trophy,
|
||||
description: 'Compete as an individual',
|
||||
filter: (league) => league.scoring?.primaryChampionshipType === 'driver',
|
||||
},
|
||||
{
|
||||
id: 'team',
|
||||
label: 'Team',
|
||||
icon: Users,
|
||||
description: 'Race together as a team',
|
||||
filter: (league) => league.scoring?.primaryChampionshipType === 'team',
|
||||
},
|
||||
{
|
||||
id: 'nations',
|
||||
label: 'Nations',
|
||||
icon: Flag,
|
||||
description: 'Represent your country',
|
||||
filter: (league) => league.scoring?.primaryChampionshipType === 'nations',
|
||||
},
|
||||
{
|
||||
id: 'trophy',
|
||||
label: 'Trophy',
|
||||
icon: Award,
|
||||
description: 'Special championship events',
|
||||
filter: (league) => league.scoring?.primaryChampionshipType === 'trophy',
|
||||
},
|
||||
{
|
||||
id: 'endurance',
|
||||
label: 'Endurance',
|
||||
icon: Timer,
|
||||
description: 'Long-distance racing',
|
||||
filter: (league) =>
|
||||
league.scoring?.scoringPresetId?.includes('endurance') ??
|
||||
league.timingSummary?.includes('h Race') ??
|
||||
false,
|
||||
},
|
||||
{
|
||||
id: 'sprint',
|
||||
label: 'Sprint',
|
||||
icon: Clock,
|
||||
description: 'Quick, intense races',
|
||||
filter: (league) =>
|
||||
(league.scoring?.scoringPresetId?.includes('sprint') ?? false) &&
|
||||
!(league.scoring?.scoringPresetId?.includes('endurance') ?? false),
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// LEAGUE SLIDER COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
function LeagueSlider({
|
||||
title,
|
||||
icon: Icon,
|
||||
description,
|
||||
leagues,
|
||||
onLeagueClick,
|
||||
autoScroll = true,
|
||||
iconColor = 'text-primary-blue',
|
||||
scrollSpeedMultiplier = 1,
|
||||
scrollDirection = 'right',
|
||||
}: LeagueSliderProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(true);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const animationRef = useRef<number | null>(null);
|
||||
const scrollPositionRef = useRef(0);
|
||||
|
||||
const checkScrollButtons = useCallback(() => {
|
||||
if (scrollRef.current) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||
setCanScrollLeft(scrollLeft > 0);
|
||||
setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 10);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scroll = useCallback((direction: 'left' | 'right') => {
|
||||
if (scrollRef.current) {
|
||||
const cardWidth = 340;
|
||||
const scrollAmount = direction === 'left' ? -cardWidth : cardWidth;
|
||||
// Update the ref so auto-scroll continues from new position
|
||||
scrollPositionRef.current = scrollRef.current.scrollLeft + scrollAmount;
|
||||
scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize scroll position for left-scrolling sliders
|
||||
const initializeScroll = useCallback(() => {
|
||||
if (scrollDirection === 'left' && scrollRef.current) {
|
||||
const { scrollWidth, clientWidth } = scrollRef.current;
|
||||
scrollPositionRef.current = scrollWidth - clientWidth;
|
||||
scrollRef.current.scrollLeft = scrollPositionRef.current;
|
||||
}
|
||||
}, [scrollDirection]);
|
||||
|
||||
// Smooth continuous auto-scroll using requestAnimationFrame with variable speed and direction
|
||||
const setupAutoScroll = useCallback(() => {
|
||||
// Allow scroll even with just 2 leagues (minimum threshold = 1)
|
||||
if (!autoScroll || leagues.length <= 1) return;
|
||||
|
||||
const scrollContainer = scrollRef.current;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
let lastTimestamp = 0;
|
||||
// Base speed with multiplier for variation between sliders
|
||||
const baseSpeed = 0.025;
|
||||
const scrollSpeed = baseSpeed * scrollSpeedMultiplier;
|
||||
const directionMultiplier = scrollDirection === 'left' ? -1 : 1;
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (!isHovering && scrollContainer) {
|
||||
const delta = lastTimestamp ? timestamp - lastTimestamp : 0;
|
||||
lastTimestamp = timestamp;
|
||||
|
||||
scrollPositionRef.current += scrollSpeed * delta * directionMultiplier;
|
||||
|
||||
const { scrollWidth, clientWidth } = scrollContainer;
|
||||
const maxScroll = scrollWidth - clientWidth;
|
||||
|
||||
// Handle wrap-around for both directions
|
||||
if (scrollDirection === 'right' && scrollPositionRef.current >= maxScroll) {
|
||||
scrollPositionRef.current = 0;
|
||||
} else if (scrollDirection === 'left' && scrollPositionRef.current <= 0) {
|
||||
scrollPositionRef.current = maxScroll;
|
||||
}
|
||||
|
||||
scrollContainer.scrollLeft = scrollPositionRef.current;
|
||||
} else {
|
||||
lastTimestamp = timestamp;
|
||||
}
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [autoScroll, leagues.length, isHovering, scrollSpeedMultiplier, scrollDirection]);
|
||||
|
||||
// Sync scroll position when user manually scrolls
|
||||
const setupManualScroll = useCallback(() => {
|
||||
const scrollContainer = scrollRef.current;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
scrollPositionRef.current = scrollContainer.scrollLeft;
|
||||
checkScrollButtons();
|
||||
};
|
||||
|
||||
scrollContainer.addEventListener('scroll', handleScroll);
|
||||
return () => scrollContainer.removeEventListener('scroll', handleScroll);
|
||||
}, [checkScrollButtons]);
|
||||
|
||||
// Initialize effects
|
||||
useState(() => {
|
||||
initializeScroll();
|
||||
});
|
||||
|
||||
// Setup auto-scroll effect
|
||||
useState(() => {
|
||||
setupAutoScroll();
|
||||
});
|
||||
|
||||
// Setup manual scroll effect
|
||||
useState(() => {
|
||||
setupManualScroll();
|
||||
});
|
||||
|
||||
if (leagues.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-10">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-xl bg-iron-gray border border-charcoal-outline`}>
|
||||
<Icon className={`w-5 h-5 ${iconColor}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||
<p className="text-xs text-gray-500">{description}</p>
|
||||
</div>
|
||||
<span className="ml-2 px-2 py-0.5 rounded-full text-xs bg-charcoal-outline/50 text-gray-400">
|
||||
{leagues.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation arrows */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scroll('left')}
|
||||
disabled={!canScrollLeft}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-all ${
|
||||
canScrollLeft
|
||||
? 'bg-iron-gray border border-charcoal-outline text-white hover:border-primary-blue hover:text-primary-blue'
|
||||
: 'bg-iron-gray/30 border border-charcoal-outline/30 text-gray-600 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scroll('right')}
|
||||
disabled={!canScrollRight}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-all ${
|
||||
canScrollRight
|
||||
? 'bg-iron-gray border border-charcoal-outline text-white hover:border-primary-blue hover:text-primary-blue'
|
||||
: 'bg-iron-gray/30 border border-charcoal-outline/30 text-gray-600 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable container with fade edges */}
|
||||
<div className="relative">
|
||||
{/* Left fade gradient */}
|
||||
<div className="absolute left-0 top-0 bottom-4 w-12 bg-gradient-to-r from-deep-graphite to-transparent z-10 pointer-events-none" />
|
||||
{/* Right fade gradient */}
|
||||
<div className="absolute right-0 top-0 bottom-4 w-12 bg-gradient-to-l from-deep-graphite to-transparent z-10 pointer-events-none" />
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
className="flex gap-4 overflow-x-auto pb-4 px-4"
|
||||
style={{
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
}}
|
||||
>
|
||||
<style jsx>{`
|
||||
div::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`}</style>
|
||||
{leagues.map((league) => (
|
||||
<div key={league.id} className="flex-shrink-0 w-[320px] h-full">
|
||||
<LeagueCard league={league} onClick={() => onLeagueClick(league.id)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN TEMPLATE COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function LeaguesTemplate({
|
||||
leagues,
|
||||
loading = false,
|
||||
onLeagueClick,
|
||||
onCreateLeagueClick,
|
||||
}: LeaguesTemplateProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryId>('all');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Filter by search query
|
||||
const searchFilteredLeagues = leagues.filter((league) => {
|
||||
if (!searchQuery) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
league.name.toLowerCase().includes(query) ||
|
||||
(league.description ?? '').toLowerCase().includes(query) ||
|
||||
(league.scoring?.gameName ?? '').toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
// Get leagues for active category
|
||||
const activeCategoryData = CATEGORIES.find((c) => c.id === activeCategory);
|
||||
const categoryFilteredLeagues = activeCategoryData
|
||||
? searchFilteredLeagues.filter(activeCategoryData.filter)
|
||||
: searchFilteredLeagues;
|
||||
|
||||
// Group leagues by category for slider view
|
||||
const leaguesByCategory = CATEGORIES.reduce(
|
||||
(acc, category) => {
|
||||
// First try to use the dedicated category field, fall back to scoring-based filtering
|
||||
acc[category.id] = searchFilteredLeagues.filter((league) => {
|
||||
// If league has a category field, use it directly
|
||||
if (league.category) {
|
||||
return league.category === category.id;
|
||||
}
|
||||
// Otherwise fall back to the existing scoring-based filter
|
||||
return category.filter(league);
|
||||
});
|
||||
return acc;
|
||||
},
|
||||
{} as Record<CategoryId, LeagueSummaryViewModel[]>,
|
||||
);
|
||||
|
||||
// Featured categories to show as sliders with different scroll speeds and alternating directions
|
||||
const featuredCategoriesWithSpeed: { id: CategoryId; speed: number; direction: 'left' | 'right' }[] = [
|
||||
{ id: 'popular', speed: 1.0, direction: 'right' },
|
||||
{ id: 'new', speed: 1.3, direction: 'left' },
|
||||
{ id: 'driver', speed: 0.8, direction: 'right' },
|
||||
{ id: 'team', speed: 1.1, direction: 'left' },
|
||||
{ id: 'nations', speed: 0.9, direction: 'right' },
|
||||
{ id: 'endurance', speed: 0.7, direction: 'left' },
|
||||
{ id: 'sprint', speed: 1.2, direction: 'right' },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-gray-400">Loading leagues...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 pb-12">
|
||||
{/* Hero Section */}
|
||||
<div className="relative mb-10 py-10 px-8 rounded-2xl bg-gradient-to-br from-iron-gray/80 via-deep-graphite to-iron-gray/60 border border-charcoal-outline/50 overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-primary-blue/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 left-0 w-64 h-64 bg-neon-aqua/5 rounded-full blur-3xl" />
|
||||
|
||||
<div className="relative z-10 flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8">
|
||||
<div className="max-w-2xl">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/20">
|
||||
<Trophy className="w-6 h-6 text-primary-blue" />
|
||||
</div>
|
||||
<Heading level={1} className="text-3xl lg:text-4xl">
|
||||
Find Your Grid
|
||||
</Heading>
|
||||
</div>
|
||||
<p className="text-gray-400 text-lg leading-relaxed mb-6">
|
||||
From casual sprints to epic endurance battles — discover the perfect league for your racing style.
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-performance-green animate-pulse" />
|
||||
<span className="text-sm text-gray-400">
|
||||
<span className="text-white font-semibold">{leagues.length}</span> active leagues
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-primary-blue" />
|
||||
<span className="text-sm text-gray-400">
|
||||
<span className="text-white font-semibold">{leaguesByCategory.new.length}</span> new this week
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-neon-aqua" />
|
||||
<span className="text-sm text-gray-400">
|
||||
<span className="text-white font-semibold">{leaguesByCategory.openSlots.length}</span> with open slots
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onCreateLeagueClick}
|
||||
className="flex items-center gap-2 px-6 py-3"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<span>Create League</span>
|
||||
</Button>
|
||||
<p className="text-xs text-gray-500 text-center">Set up your own racing series</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search leagues by name, description, or game..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle (mobile) */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="lg:hidden flex items-center gap-2"
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Filters
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
<div className={`mt-4 ${showFilters ? 'block' : 'hidden lg:block'}`}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATEGORIES.map((category) => {
|
||||
const Icon = category.icon;
|
||||
const count = leaguesByCategory[category.id].length;
|
||||
const isActive = activeCategory === category.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(category.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-primary-blue text-white shadow-[0_0_15px_rgba(25,140,255,0.3)]'
|
||||
: 'bg-iron-gray/60 text-gray-400 border border-charcoal-outline hover:border-gray-500 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-3.5 h-3.5 ${!isActive && category.color ? category.color : ''}`} />
|
||||
<span>{category.label}</span>
|
||||
{count > 0 && (
|
||||
<span className={`px-1.5 py-0.5 rounded-full text-[10px] ${isActive ? 'bg-white/20' : 'bg-charcoal-outline/50'}`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{leagues.length === 0 ? (
|
||||
/* Empty State */
|
||||
<Card className="text-center py-16">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-2xl bg-primary-blue/10 border border-primary-blue/20 mb-6">
|
||||
<Trophy className="w-8 h-8 text-primary-blue" />
|
||||
</div>
|
||||
<Heading level={2} className="text-2xl mb-3">
|
||||
No leagues yet
|
||||
</Heading>
|
||||
<p className="text-gray-400 mb-8">
|
||||
Be the first to create a racing series. Start your own league and invite drivers to compete for glory.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onCreateLeagueClick}
|
||||
className="flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Create Your First League
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : activeCategory === 'all' && !searchQuery ? (
|
||||
/* Slider View - Show featured categories with sliders at different speeds and directions */
|
||||
<div>
|
||||
{featuredCategoriesWithSpeed
|
||||
.map(({ id, speed, direction }) => {
|
||||
const category = CATEGORIES.find((c) => c.id === id)!;
|
||||
return { category, speed, direction };
|
||||
})
|
||||
.filter(({ category }) => leaguesByCategory[category.id].length > 0)
|
||||
.map(({ category, speed, direction }) => (
|
||||
<LeagueSlider
|
||||
key={category.id}
|
||||
title={category.label}
|
||||
icon={category.icon}
|
||||
description={category.description}
|
||||
leagues={leaguesByCategory[category.id]}
|
||||
onLeagueClick={onLeagueClick}
|
||||
autoScroll={true}
|
||||
iconColor={category.color || 'text-primary-blue'}
|
||||
scrollSpeedMultiplier={speed}
|
||||
scrollDirection={direction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Grid View - Filtered by category or search */
|
||||
<div>
|
||||
{categoryFilteredLeagues.length > 0 ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<p className="text-sm text-gray-400">
|
||||
Showing <span className="text-white font-medium">{categoryFilteredLeagues.length}</span>{' '}
|
||||
{categoryFilteredLeagues.length === 1 ? 'league' : 'leagues'}
|
||||
{searchQuery && (
|
||||
<span>
|
||||
{' '}
|
||||
for "<span className="text-primary-blue">{searchQuery}</span>"
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{categoryFilteredLeagues.map((league) => (
|
||||
<LeagueCard key={league.id} league={league} onClick={() => onLeagueClick(league.id)} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Card className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Search className="w-10 h-10 text-gray-600" />
|
||||
<p className="text-gray-400">
|
||||
No leagues found{searchQuery ? ` matching "${searchQuery}"` : ' in this category'}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setActiveCategory('all');
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
853
apps/website/templates/RaceDetailTemplate.tsx
Normal file
853
apps/website/templates/RaceDetailTemplate.tsx
Normal file
@@ -0,0 +1,853 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import { RaceJoinButton } from '@/components/races/RaceJoinButton';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
Car,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Flag,
|
||||
PlayCircle,
|
||||
Scale,
|
||||
Trophy,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
Users,
|
||||
XCircle,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface RaceDetailEntryViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
country: string;
|
||||
rating?: number | null;
|
||||
isCurrentUser: boolean;
|
||||
}
|
||||
|
||||
export interface RaceDetailUserResultViewModel {
|
||||
position: number;
|
||||
startPosition: number;
|
||||
positionChange: number;
|
||||
incidents: number;
|
||||
isClean: boolean;
|
||||
isPodium: boolean;
|
||||
ratingChange?: number;
|
||||
}
|
||||
|
||||
export interface RaceDetailLeague {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
settings: {
|
||||
maxDrivers: number;
|
||||
qualifyingFormat: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RaceDetailRace {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
sessionType: string;
|
||||
}
|
||||
|
||||
export interface RaceDetailRegistration {
|
||||
isUserRegistered: boolean;
|
||||
canRegister: boolean;
|
||||
}
|
||||
|
||||
export interface RaceDetailViewModel {
|
||||
race: RaceDetailRace;
|
||||
league?: RaceDetailLeague;
|
||||
entryList: RaceDetailEntryViewModel[];
|
||||
registration: RaceDetailRegistration;
|
||||
userResult?: RaceDetailUserResultViewModel;
|
||||
canReopenRace: boolean;
|
||||
}
|
||||
|
||||
export interface RaceDetailTemplateProps {
|
||||
viewModel?: RaceDetailViewModel;
|
||||
isLoading: boolean;
|
||||
error?: Error | null;
|
||||
// Actions
|
||||
onBack: () => void;
|
||||
onRegister: () => void;
|
||||
onWithdraw: () => void;
|
||||
onCancel: () => void;
|
||||
onReopen: () => void;
|
||||
onEndRace: () => void;
|
||||
onFileProtest: () => void;
|
||||
onResultsClick: () => void;
|
||||
onStewardingClick: () => void;
|
||||
onLeagueClick: (leagueId: string) => void;
|
||||
onDriverClick: (driverId: string) => void;
|
||||
// User state
|
||||
currentDriverId?: string;
|
||||
isOwnerOrAdmin?: boolean;
|
||||
// UI State
|
||||
showProtestModal: boolean;
|
||||
setShowProtestModal: (show: boolean) => void;
|
||||
showEndRaceModal: boolean;
|
||||
setShowEndRaceModal: (show: boolean) => void;
|
||||
// Loading states
|
||||
mutationLoading?: {
|
||||
register?: boolean;
|
||||
withdraw?: boolean;
|
||||
cancel?: boolean;
|
||||
reopen?: boolean;
|
||||
complete?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function RaceDetailTemplate({
|
||||
viewModel,
|
||||
isLoading,
|
||||
error,
|
||||
onBack,
|
||||
onRegister,
|
||||
onWithdraw,
|
||||
onCancel,
|
||||
onReopen,
|
||||
onEndRace,
|
||||
onFileProtest,
|
||||
onResultsClick,
|
||||
onStewardingClick,
|
||||
onLeagueClick,
|
||||
onDriverClick,
|
||||
currentDriverId,
|
||||
isOwnerOrAdmin = false,
|
||||
showProtestModal,
|
||||
setShowProtestModal,
|
||||
showEndRaceModal,
|
||||
setShowEndRaceModal,
|
||||
mutationLoading = {},
|
||||
}: RaceDetailTemplateProps) {
|
||||
const [ratingChange, setRatingChange] = useState<number | null>(null);
|
||||
const [animatedRatingChange, setAnimatedRatingChange] = useState(0);
|
||||
|
||||
// Set rating change when viewModel changes
|
||||
useEffect(() => {
|
||||
if (viewModel?.userResult?.ratingChange !== undefined) {
|
||||
setRatingChange(viewModel.userResult.ratingChange);
|
||||
}
|
||||
}, [viewModel?.userResult?.ratingChange]);
|
||||
|
||||
// Animate rating change when it changes
|
||||
useEffect(() => {
|
||||
if (ratingChange !== null) {
|
||||
let start = 0;
|
||||
const end = ratingChange;
|
||||
const duration = 1000;
|
||||
const startTime = performance.now();
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
const current = Math.round(start + (end - start) * eased);
|
||||
setAnimatedRatingChange(current);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
}, [ratingChange]);
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return new Date(date).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
};
|
||||
|
||||
const getTimeUntil = (date: Date) => {
|
||||
const now = new Date();
|
||||
const target = new Date(date);
|
||||
const diffMs = target.getTime() - now.getTime();
|
||||
|
||||
if (diffMs < 0) return null;
|
||||
|
||||
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
scheduled: {
|
||||
icon: Clock,
|
||||
color: 'text-primary-blue',
|
||||
bg: 'bg-primary-blue/10',
|
||||
border: 'border-primary-blue/30',
|
||||
label: 'Scheduled',
|
||||
description: 'This race is scheduled and waiting to start',
|
||||
},
|
||||
running: {
|
||||
icon: PlayCircle,
|
||||
color: 'text-performance-green',
|
||||
bg: 'bg-performance-green/10',
|
||||
border: 'border-performance-green/30',
|
||||
label: 'LIVE NOW',
|
||||
description: 'This race is currently in progress',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-gray-400',
|
||||
bg: 'bg-gray-500/10',
|
||||
border: 'border-gray-500/30',
|
||||
label: 'Completed',
|
||||
description: 'This race has finished',
|
||||
},
|
||||
cancelled: {
|
||||
icon: XCircle,
|
||||
color: 'text-warning-amber',
|
||||
bg: 'bg-warning-amber/10',
|
||||
border: 'border-warning-amber/30',
|
||||
label: 'Cancelled',
|
||||
description: 'This race has been cancelled',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const getCountryFlag = (countryCode: string): string => {
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(char => 127397 + char.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-6 bg-iron-gray rounded w-1/4" />
|
||||
<div className="h-48 bg-iron-gray rounded-xl" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 h-64 bg-iron-gray rounded-xl" />
|
||||
<div className="h-64 bg-iron-gray rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !viewModel || !viewModel.race) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Breadcrumbs items={[{ label: 'Races', href: '/races' }, { label: 'Error' }]} />
|
||||
|
||||
<Card className="text-center py-12 mt-6">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="p-4 bg-warning-amber/10 rounded-full">
|
||||
<AlertTriangle className="w-8 h-8 text-warning-amber" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium mb-1">{error instanceof Error ? error.message : error || 'Race not found'}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
The race you're looking for doesn't exist or has been removed.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
className="mt-4"
|
||||
>
|
||||
Back to Races
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const race = viewModel.race;
|
||||
const league = viewModel.league;
|
||||
const entryList = viewModel.entryList;
|
||||
const userResult = viewModel.userResult;
|
||||
const raceSOF = null; // TODO: Add strength of field to race details response
|
||||
|
||||
const config = statusConfig[race.status as keyof typeof statusConfig];
|
||||
const StatusIcon = config.icon;
|
||||
const timeUntil = race.status === 'scheduled' ? getTimeUntil(new Date(race.scheduledAt)) : null;
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: 'Races', href: '/races' },
|
||||
...(league ? [{ label: league.name, href: `/leagues/${league.id}` }] : []),
|
||||
{ label: race.track },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Navigation Row: Breadcrumbs left, Back button right */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Breadcrumbs items={breadcrumbItems} className="text-sm text-gray-400" />
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* User Result - Premium Achievement Card */}
|
||||
{userResult && (
|
||||
<div
|
||||
className={`
|
||||
relative overflow-hidden rounded-2xl p-1
|
||||
${
|
||||
userResult.position === 1
|
||||
? 'bg-gradient-to-r from-yellow-500 via-yellow-400 to-yellow-600'
|
||||
: userResult.isPodium
|
||||
? 'bg-gradient-to-r from-gray-400 via-gray-300 to-gray-500'
|
||||
: 'bg-gradient-to-r from-primary-blue via-primary-blue/80 to-primary-blue'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="relative bg-deep-graphite rounded-xl p-6 sm:p-8">
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute top-0 left-0 w-32 h-32 bg-gradient-to-br from-white/10 to-transparent rounded-full blur-2xl" />
|
||||
<div className="absolute bottom-0 right-0 w-48 h-48 bg-gradient-to-tl from-white/5 to-transparent rounded-full blur-3xl" />
|
||||
|
||||
{/* Victory confetti effect for P1 */}
|
||||
{userResult.position === 1 && (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-4 left-[10%] w-2 h-2 bg-yellow-400 rounded-full animate-pulse" />
|
||||
<div className="absolute top-8 left-[25%] w-1.5 h-1.5 bg-yellow-300 rounded-full animate-pulse delay-100" />
|
||||
<div className="absolute top-6 right-[20%] w-2 h-2 bg-yellow-500 rounded-full animate-pulse delay-200" />
|
||||
<div className="absolute top-10 right-[35%] w-1 h-1 bg-yellow-400 rounded-full animate-pulse delay-300" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10">
|
||||
{/* Main content grid */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
|
||||
{/* Left: Position and achievement */}
|
||||
<div className="flex items-center gap-5">
|
||||
{/* Giant position badge */}
|
||||
<div
|
||||
className={`
|
||||
relative flex items-center justify-center w-24 h-24 sm:w-28 sm:h-28 rounded-3xl font-black text-4xl sm:text-5xl
|
||||
${
|
||||
userResult.position === 1
|
||||
? 'bg-gradient-to-br from-yellow-400 to-yellow-600 text-deep-graphite shadow-2xl shadow-yellow-500/30'
|
||||
: userResult.position === 2
|
||||
? 'bg-gradient-to-br from-gray-300 to-gray-500 text-deep-graphite shadow-xl shadow-gray-400/20'
|
||||
: userResult.position === 3
|
||||
? 'bg-gradient-to-br from-amber-600 to-amber-800 text-white shadow-xl shadow-amber-600/20'
|
||||
: 'bg-gradient-to-br from-primary-blue to-primary-blue/70 text-white shadow-xl shadow-primary-blue/20'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{userResult.position === 1 && (
|
||||
<Trophy className="absolute -top-3 -right-2 w-8 h-8 text-yellow-300 drop-shadow-lg" />
|
||||
)}
|
||||
<span>P{userResult.position}</span>
|
||||
</div>
|
||||
|
||||
{/* Achievement text */}
|
||||
<div>
|
||||
<p
|
||||
className={`
|
||||
text-2xl sm:text-3xl font-bold mb-1
|
||||
${
|
||||
userResult.position === 1
|
||||
? 'text-yellow-400'
|
||||
: userResult.isPodium
|
||||
? 'text-gray-300'
|
||||
: 'text-white'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{userResult.position === 1
|
||||
? '🏆 VICTORY!'
|
||||
: userResult.position === 2
|
||||
? '🥈 Second Place'
|
||||
: userResult.position === 3
|
||||
? '🥉 Podium Finish'
|
||||
: userResult.position <= 5
|
||||
? '⭐ Top 5 Finish'
|
||||
: userResult.position <= 10
|
||||
? 'Points Finish'
|
||||
: `P${userResult.position} Finish`}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 text-sm text-gray-400">
|
||||
<span>Started P{userResult.startPosition}</span>
|
||||
<span className="w-1 h-1 rounded-full bg-gray-600" />
|
||||
<span className={userResult.isClean ? 'text-performance-green' : ''}>
|
||||
{userResult.incidents}x incidents
|
||||
{userResult.isClean && ' ✨'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Stats cards */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* Position change */}
|
||||
{userResult.positionChange !== 0 && (
|
||||
<div
|
||||
className={`
|
||||
flex flex-col items-center px-5 py-3 rounded-2xl min-w-[100px]
|
||||
${
|
||||
userResult.positionChange > 0
|
||||
? 'bg-gradient-to-br from-performance-green/30 to-performance-green/10 border border-performance-green/40'
|
||||
: 'bg-gradient-to-br from-red-500/30 to-red-500/10 border border-red-500/40'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-1 font-black text-2xl
|
||||
${
|
||||
userResult.positionChange > 0
|
||||
? 'text-performance-green'
|
||||
: 'text-red-400'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{userResult.positionChange > 0 ? (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 17a.75.75 0 01-.75-.75V5.612L5.29 9.77a.75.75 0 01-1.08-1.04l5.25-5.5a.75.75 0 011.08 0l5.25 5.5a.75.75 0 11-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0110 17z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{Math.abs(userResult.positionChange)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">
|
||||
{userResult.positionChange > 0 ? 'Gained' : 'Lost'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rating change */}
|
||||
{ratingChange !== null && (
|
||||
<div
|
||||
className={`
|
||||
flex flex-col items-center px-5 py-3 rounded-2xl min-w-[100px]
|
||||
${
|
||||
ratingChange > 0
|
||||
? 'bg-gradient-to-br from-warning-amber/30 to-warning-amber/10 border border-warning-amber/40'
|
||||
: 'bg-gradient-to-br from-red-500/30 to-red-500/10 border border-red-500/40'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
font-mono font-black text-2xl
|
||||
${ratingChange > 0 ? 'text-warning-amber' : 'text-red-400'}
|
||||
`}
|
||||
>
|
||||
{animatedRatingChange > 0 ? '+' : ''}
|
||||
{animatedRatingChange}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">Rating</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clean race bonus */}
|
||||
{userResult.isClean && (
|
||||
<div className="flex flex-col items-center px-5 py-3 rounded-2xl min-w-[100px] bg-gradient-to-br from-performance-green/30 to-performance-green/10 border border-performance-green/40">
|
||||
<div className="text-2xl">✨</div>
|
||||
<div className="text-xs text-performance-green mt-0.5 font-medium">
|
||||
Clean Race
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hero Header */}
|
||||
<div className={`relative overflow-hidden rounded-2xl ${config.bg} border ${config.border} p-6 sm:p-8`}>
|
||||
{/* Live indicator */}
|
||||
{race.status === 'running' && (
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-performance-green via-performance-green/50 to-performance-green animate-pulse" />
|
||||
)}
|
||||
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full blur-3xl" />
|
||||
|
||||
<div className="relative z-10">
|
||||
{/* Status Badge */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full ${config.bg} border ${config.border}`}>
|
||||
{race.status === 'running' && (
|
||||
<span className="w-2 h-2 bg-performance-green rounded-full animate-pulse" />
|
||||
)}
|
||||
<StatusIcon className={`w-4 h-4 ${config.color}`} />
|
||||
<span className={`text-sm font-semibold ${config.color}`}>{config.label}</span>
|
||||
</div>
|
||||
{timeUntil && (
|
||||
<span className="text-sm text-gray-400">
|
||||
Starts in <span className="text-white font-medium">{timeUntil}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<Heading level={1} className="text-2xl sm:text-3xl font-bold text-white mb-2">
|
||||
{race.track}
|
||||
</Heading>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-gray-400">
|
||||
<span className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{formatDate(new Date(race.scheduledAt))}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatTime(new Date(race.scheduledAt))}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Car className="w-4 h-4" />
|
||||
{race.car}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Prominent SOF Badge - Electric Design */}
|
||||
{raceSOF != null && (
|
||||
<div className="absolute top-6 right-6 sm:top-8 sm:right-8">
|
||||
<div className="relative group">
|
||||
{/* Glow effect */}
|
||||
<div className="absolute inset-0 bg-warning-amber/40 rounded-2xl blur-xl group-hover:blur-2xl transition-all duration-300" />
|
||||
|
||||
<div className="relative flex items-center gap-4 px-6 py-4 rounded-2xl bg-gradient-to-br from-warning-amber/30 via-warning-amber/20 to-orange-500/20 border border-warning-amber/50 shadow-2xl backdrop-blur-sm">
|
||||
{/* Electric bolt with animation */}
|
||||
<div className="relative">
|
||||
<Zap className="w-8 h-8 text-warning-amber drop-shadow-lg" />
|
||||
<Zap className="absolute inset-0 w-8 h-8 text-warning-amber animate-pulse opacity-50" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] text-warning-amber/90 uppercase tracking-widest font-bold mb-0.5">
|
||||
Strength of Field
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-3xl font-black text-warning-amber font-mono tracking-tight drop-shadow-lg">
|
||||
{raceSOF}
|
||||
</span>
|
||||
<span className="text-sm text-warning-amber/70 font-medium">SOF</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Race Details */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Flag className="w-5 h-5 text-primary-blue" />
|
||||
Race Details
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-deep-graphite rounded-lg">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Track</p>
|
||||
<p className="text-white font-medium">{race.track}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-deep-graphite rounded-lg">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Car</p>
|
||||
<p className="text-white font-medium">{race.car}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-deep-graphite rounded-lg">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Session Type</p>
|
||||
<p className="text-white font-medium capitalize">{race.sessionType}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-deep-graphite rounded-lg">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Status</p>
|
||||
<p className={`font-medium ${config.color}`}>{config.label}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-deep-graphite rounded-lg">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Strength of Field</p>
|
||||
<p className="text-warning-amber font-medium flex items-center gap-1.5">
|
||||
<Zap className="w-4 h-4" />
|
||||
{raceSOF ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Entry List */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-primary-blue" />
|
||||
Entry List
|
||||
</h2>
|
||||
<span className="text-sm text-gray-400">
|
||||
{entryList.length} driver{entryList.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{entryList.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="p-4 bg-iron-gray rounded-full inline-block mb-3">
|
||||
<Users className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
<p className="text-gray-400">No drivers registered yet</p>
|
||||
<p className="text-sm text-gray-500">Be the first to sign up!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{entryList.map((driver, index) => {
|
||||
const isCurrentUser = driver.isCurrentUser;
|
||||
const countryFlag = getCountryFlag(driver.country);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={driver.id}
|
||||
onClick={() => onDriverClick(driver.id)}
|
||||
className={`
|
||||
flex items-center gap-3 p-3 rounded-xl cursor-pointer transition-all duration-200
|
||||
${
|
||||
isCurrentUser
|
||||
? 'bg-gradient-to-r from-primary-blue/20 via-primary-blue/10 to-transparent border border-primary-blue/40 shadow-lg shadow-primary-blue/10'
|
||||
: 'bg-deep-graphite hover:bg-charcoal-outline/50 border border-transparent'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Position number */}
|
||||
<div
|
||||
className={`
|
||||
flex items-center justify-center w-8 h-8 rounded-lg font-bold text-sm
|
||||
${
|
||||
race.status === 'completed' && index === 0
|
||||
? 'bg-yellow-500/20 text-yellow-400'
|
||||
: race.status === 'completed' && index === 1
|
||||
? 'bg-gray-400/20 text-gray-300'
|
||||
: race.status === 'completed' && index === 2
|
||||
? 'bg-amber-600/20 text-amber-500'
|
||||
: 'bg-iron-gray text-gray-500'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Avatar with nation flag */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<img
|
||||
src={driver.avatarUrl}
|
||||
alt={driver.name}
|
||||
className={`
|
||||
w-10 h-10 rounded-full object-cover
|
||||
${isCurrentUser ? 'ring-2 ring-primary-blue/50' : ''}
|
||||
`}
|
||||
/>
|
||||
{/* Nation flag */}
|
||||
<div className="absolute -bottom-0.5 -right-0.5 w-5 h-5 rounded-full bg-deep-graphite border-2 border-deep-graphite flex items-center justify-center text-xs shadow-sm">
|
||||
{countryFlag}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Driver info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={`text-sm font-semibold truncate ${
|
||||
isCurrentUser ? 'text-primary-blue' : 'text-white'
|
||||
}`}
|
||||
>
|
||||
{driver.name}
|
||||
</p>
|
||||
{isCurrentUser && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-bold bg-primary-blue text-white rounded-full uppercase tracking-wide">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{driver.country}</p>
|
||||
</div>
|
||||
|
||||
{/* Rating badge */}
|
||||
{driver.rating != null && (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-warning-amber/10 border border-warning-amber/20">
|
||||
<Zap className="w-3 h-3 text-warning-amber" />
|
||||
<span className="text-xs font-bold text-warning-amber font-mono">
|
||||
{driver.rating}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* League Card - Premium Design */}
|
||||
{league && (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-14 h-14 rounded-xl overflow-hidden bg-iron-gray flex-shrink-0">
|
||||
<img
|
||||
src={`league-logo-${league.id}`}
|
||||
alt={league.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-0.5">League</p>
|
||||
<h3 className="text-white font-semibold truncate">{league.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{league.description && (
|
||||
<p className="text-sm text-gray-400 mb-4 line-clamp-2">{league.description}</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
<div className="p-3 rounded-lg bg-deep-graphite">
|
||||
<p className="text-xs text-gray-500 mb-1">Max Drivers</p>
|
||||
<p className="text-white font-medium">{(league.settings as any).maxDrivers ?? 32}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-deep-graphite">
|
||||
<p className="text-xs text-gray-500 mb-1">Format</p>
|
||||
<p className="text-white font-medium capitalize">
|
||||
{(league.settings as any).qualifyingFormat ?? 'Open'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/leagues/${league.id}`}
|
||||
className="flex items-center justify-center gap-2 w-full py-2.5 rounded-lg bg-primary-blue/10 border border-primary-blue/30 text-primary-blue text-sm font-medium hover:bg-primary-blue/20 transition-colors"
|
||||
>
|
||||
View League
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick Actions Card */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Actions</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Registration Actions */}
|
||||
<RaceJoinButton
|
||||
raceStatus={race.status}
|
||||
isUserRegistered={viewModel.registration.isUserRegistered}
|
||||
canRegister={viewModel.registration.canRegister}
|
||||
onRegister={onRegister}
|
||||
onWithdraw={onWithdraw}
|
||||
onCancel={onCancel}
|
||||
onReopen={onReopen}
|
||||
onEndRace={onEndRace}
|
||||
canReopenRace={viewModel.canReopenRace}
|
||||
isOwnerOrAdmin={isOwnerOrAdmin}
|
||||
isLoading={mutationLoading}
|
||||
/>
|
||||
|
||||
{/* Results and Stewarding for completed races */}
|
||||
{race.status === 'completed' && (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={onResultsClick}
|
||||
>
|
||||
<Trophy className="w-4 h-4" />
|
||||
View Results
|
||||
</Button>
|
||||
{userResult && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={onFileProtest}
|
||||
>
|
||||
<Scale className="w-4 h-4" />
|
||||
File Protest
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={onStewardingClick}
|
||||
>
|
||||
<Scale className="w-4 h-4" />
|
||||
Stewarding
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Status Info */}
|
||||
<Card className={`${config.bg} border ${config.border}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg ${config.bg}`}>
|
||||
<StatusIcon className={`w-5 h-5 ${config.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-medium ${config.color}`}>{config.label}</p>
|
||||
<p className="text-sm text-gray-400 mt-1">{config.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals would be rendered by parent */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
363
apps/website/templates/RaceResultsTemplate.tsx
Normal file
363
apps/website/templates/RaceResultsTemplate.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
'use client';
|
||||
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { ArrowLeft, Calendar, Trophy, Users, Zap } from 'lucide-react';
|
||||
|
||||
export interface ResultEntry {
|
||||
position: number;
|
||||
driverId: string;
|
||||
driverName: string;
|
||||
driverAvatar: string;
|
||||
country: string;
|
||||
car: string;
|
||||
laps: number;
|
||||
time: string;
|
||||
fastestLap: string;
|
||||
points: number;
|
||||
incidents: number;
|
||||
isCurrentUser: boolean;
|
||||
}
|
||||
|
||||
export interface PenaltyEntry {
|
||||
driverId: string;
|
||||
driverName: string;
|
||||
type: 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points';
|
||||
value: number;
|
||||
reason: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface RaceResultsTemplateProps {
|
||||
raceTrack?: string;
|
||||
raceScheduledAt?: string;
|
||||
totalDrivers?: number;
|
||||
leagueName?: string;
|
||||
raceSOF?: number | null;
|
||||
results: ResultEntry[];
|
||||
penalties: PenaltyEntry[];
|
||||
pointsSystem: Record<string, number>;
|
||||
fastestLapTime: number;
|
||||
currentDriverId: string;
|
||||
isAdmin: boolean;
|
||||
isLoading: boolean;
|
||||
error?: Error | null;
|
||||
// Actions
|
||||
onBack: () => void;
|
||||
onImportResults: (results: any[]) => void;
|
||||
onPenaltyClick: (driver: { id: string; name: string }) => void;
|
||||
// UI State
|
||||
importing: boolean;
|
||||
importSuccess: boolean;
|
||||
importError: string | null;
|
||||
showImportForm: boolean;
|
||||
setShowImportForm: (show: boolean) => void;
|
||||
}
|
||||
|
||||
export function RaceResultsTemplate({
|
||||
raceTrack,
|
||||
raceScheduledAt,
|
||||
totalDrivers,
|
||||
leagueName,
|
||||
raceSOF,
|
||||
results,
|
||||
penalties,
|
||||
pointsSystem,
|
||||
fastestLapTime,
|
||||
currentDriverId,
|
||||
isAdmin,
|
||||
isLoading,
|
||||
error,
|
||||
onBack,
|
||||
onImportResults,
|
||||
onPenaltyClick,
|
||||
importing,
|
||||
importSuccess,
|
||||
importError,
|
||||
showImportForm,
|
||||
setShowImportForm,
|
||||
}: RaceResultsTemplateProps) {
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (ms: number) => {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
const milliseconds = Math.floor((ms % 1000) / 10);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const getCountryFlag = (countryCode: string): string => {
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(char => 127397 + char.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
};
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: 'Races', href: '/races' },
|
||||
...(leagueName ? [{ label: leagueName, href: `/leagues/${leagueName}` }] : []),
|
||||
...(raceTrack ? [{ label: raceTrack, href: `/races/${raceTrack}` }] : []),
|
||||
{ label: 'Results' },
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center text-gray-400">Loading results...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !raceTrack) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<Card className="text-center py-12">
|
||||
<div className="text-warning-amber mb-4">
|
||||
{error?.message || 'Race not found'}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
>
|
||||
Back to Races
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasResults = results.length > 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Breadcrumbs items={breadcrumbItems} className="text-sm text-gray-400" />
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<Card className="bg-gradient-to-r from-iron-gray/50 to-iron-gray/30">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary-blue/20 flex items-center justify-center">
|
||||
<Trophy className="w-6 h-6 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Race Results</h1>
|
||||
<p className="text-sm text-gray-400">
|
||||
{raceTrack} • {raceScheduledAt ? formatDate(raceScheduledAt) : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="p-3 bg-deep-graphite/60 rounded-lg">
|
||||
<p className="text-xs text-gray-400 mb-1">Drivers</p>
|
||||
<p className="text-lg font-bold text-white">{totalDrivers ?? 0}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-deep-graphite/60 rounded-lg">
|
||||
<p className="text-xs text-gray-400 mb-1">League</p>
|
||||
<p className="text-sm font-medium text-white truncate">{leagueName ?? '—'}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-deep-graphite/60 rounded-lg">
|
||||
<p className="text-xs text-gray-400 mb-1">SOF</p>
|
||||
<p className="text-lg font-bold text-warning-amber flex items-center gap-1">
|
||||
<Zap className="w-4 h-4" />
|
||||
{raceSOF ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-deep-graphite/60 rounded-lg">
|
||||
<p className="text-xs text-gray-400 mb-1">Fastest Lap</p>
|
||||
<p className="text-lg font-bold text-performance-green">
|
||||
{fastestLapTime ? formatTime(fastestLapTime) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{importSuccess && (
|
||||
<div className="p-4 bg-performance-green/10 border border-performance-green/30 rounded-lg text-performance-green">
|
||||
<strong>Success!</strong> Results imported and standings updated.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importError && (
|
||||
<div className="p-4 bg-warning-amber/10 border border-warning-amber/30 rounded-lg text-warning-amber">
|
||||
<strong>Error:</strong> {importError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
{hasResults ? (
|
||||
<div className="space-y-4">
|
||||
{/* Results Table */}
|
||||
<div className="space-y-2">
|
||||
{results.map((result) => {
|
||||
const isCurrentUser = result.driverId === currentDriverId;
|
||||
const countryFlag = getCountryFlag(result.country);
|
||||
const points = pointsSystem[result.position.toString()] ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.driverId}
|
||||
className={`
|
||||
flex items-center gap-3 p-3 rounded-xl
|
||||
${isCurrentUser ? 'bg-gradient-to-r from-primary-blue/20 via-primary-blue/10 to-transparent border border-primary-blue/40' : 'bg-deep-graphite'}
|
||||
`}
|
||||
>
|
||||
{/* Position */}
|
||||
<div className={`
|
||||
flex items-center justify-center w-10 h-10 rounded-lg font-bold
|
||||
${result.position === 1 ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
result.position === 2 ? 'bg-gray-400/20 text-gray-300' :
|
||||
result.position === 3 ? 'bg-amber-600/20 text-amber-500' :
|
||||
'bg-iron-gray text-gray-500'}
|
||||
`}>
|
||||
{result.position}
|
||||
</div>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<img
|
||||
src={result.driverAvatar}
|
||||
alt={result.driverName}
|
||||
className={`w-10 h-10 rounded-full object-cover ${isCurrentUser ? 'ring-2 ring-primary-blue/50' : ''}`}
|
||||
/>
|
||||
<div className="absolute -bottom-0.5 -right-0.5 w-5 h-5 rounded-full bg-deep-graphite border-2 border-deep-graphite flex items-center justify-center text-xs shadow-sm">
|
||||
{countryFlag}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Driver Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`text-sm font-semibold truncate ${isCurrentUser ? 'text-primary-blue' : 'text-white'}`}>
|
||||
{result.driverName}
|
||||
</p>
|
||||
{isCurrentUser && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-bold bg-primary-blue text-white rounded-full uppercase tracking-wide">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400 mt-0.5">
|
||||
<span>{result.car}</span>
|
||||
<span>•</span>
|
||||
<span>Laps: {result.laps}</span>
|
||||
<span>•</span>
|
||||
<span>Incidents: {result.incidents}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Times */}
|
||||
<div className="text-right min-w-[100px]">
|
||||
<p className="text-sm font-mono text-white">{result.time}</p>
|
||||
<p className="text-xs text-performance-green">FL: {result.fastestLap}</p>
|
||||
</div>
|
||||
|
||||
{/* Points */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex flex-col items-center px-3 py-1 rounded-lg bg-warning-amber/10 border border-warning-amber/20">
|
||||
<span className="text-xs text-gray-400">PTS</span>
|
||||
<span className="text-sm font-bold text-warning-amber">{points}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Penalties Section */}
|
||||
{penalties.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-charcoal-outline">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Penalties</h3>
|
||||
<div className="space-y-2">
|
||||
{penalties.map((penalty, index) => (
|
||||
<div key={index} className="flex items-center gap-3 p-3 bg-deep-graphite rounded-lg">
|
||||
<div className="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-red-400 font-bold text-sm">!</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-white">{penalty.driverName}</span>
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
|
||||
{penalty.type.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{penalty.reason}</p>
|
||||
{penalty.notes && (
|
||||
<p className="text-sm text-gray-500 mt-1 italic">{penalty.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-2xl font-bold text-red-400">
|
||||
{penalty.type === 'time_penalty' && `+${penalty.value}s`}
|
||||
{penalty.type === 'grid_penalty' && `+${penalty.value} grid`}
|
||||
{penalty.type === 'points_deduction' && `-${penalty.value} pts`}
|
||||
{penalty.type === 'disqualification' && 'DSQ'}
|
||||
{penalty.type === 'warning' && 'Warning'}
|
||||
{penalty.type === 'license_points' && `${penalty.value} LP`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold text-white mb-6">Import Results</h2>
|
||||
<p className="text-gray-400 text-sm mb-6">
|
||||
No results imported. Upload CSV to test the standings system.
|
||||
</p>
|
||||
{importing ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
Importing results and updating standings...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
This is a placeholder for the import form. In the actual implementation,
|
||||
this would render the ImportResultsForm component.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
// Mock import for demo
|
||||
onImportResults([]);
|
||||
}}
|
||||
disabled={importing}
|
||||
>
|
||||
Import Results (Demo)
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
435
apps/website/templates/RaceStewardingTemplate.tsx
Normal file
435
apps/website/templates/RaceStewardingTemplate.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import RaceStewardingStats from '@/components/races/RaceStewardingStats';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { StewardingTabs } from '@/components/races/StewardingTabs';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Flag,
|
||||
Gavel,
|
||||
Scale,
|
||||
Video
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export type StewardingTab = 'pending' | 'resolved' | 'penalties';
|
||||
|
||||
export interface Protest {
|
||||
id: string;
|
||||
status: string;
|
||||
protestingDriverId: string;
|
||||
accusedDriverId: string;
|
||||
filedAt: string;
|
||||
incident: {
|
||||
lap: number;
|
||||
description: string;
|
||||
};
|
||||
proofVideoUrl?: string;
|
||||
decisionNotes?: string;
|
||||
}
|
||||
|
||||
export interface Penalty {
|
||||
id: string;
|
||||
driverId: string;
|
||||
type: string;
|
||||
value: number;
|
||||
reason: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface Driver {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RaceStewardingData {
|
||||
race?: {
|
||||
id: string;
|
||||
track: string;
|
||||
scheduledAt: string;
|
||||
} | null;
|
||||
league?: {
|
||||
id: string;
|
||||
} | null;
|
||||
pendingProtests: Protest[];
|
||||
resolvedProtests: Protest[];
|
||||
penalties: Penalty[];
|
||||
driverMap: Record<string, Driver>;
|
||||
pendingCount: number;
|
||||
resolvedCount: number;
|
||||
penaltiesCount: number;
|
||||
}
|
||||
|
||||
export interface RaceStewardingTemplateProps {
|
||||
stewardingData?: RaceStewardingData;
|
||||
isLoading: boolean;
|
||||
error?: Error | null;
|
||||
// Actions
|
||||
onBack: () => void;
|
||||
onReviewProtest: (protestId: string) => void;
|
||||
// User state
|
||||
isAdmin: boolean;
|
||||
// UI State
|
||||
activeTab: StewardingTab;
|
||||
setActiveTab: (tab: StewardingTab) => void;
|
||||
}
|
||||
|
||||
export function RaceStewardingTemplate({
|
||||
stewardingData,
|
||||
isLoading,
|
||||
error,
|
||||
onBack,
|
||||
onReviewProtest,
|
||||
isAdmin,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
}: RaceStewardingTemplateProps) {
|
||||
const formatDate = (date: Date | string) => {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
case 'under_review':
|
||||
return (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
|
||||
Pending
|
||||
</span>
|
||||
);
|
||||
case 'upheld':
|
||||
return (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
|
||||
Upheld
|
||||
</span>
|
||||
);
|
||||
case 'dismissed':
|
||||
return (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">
|
||||
Dismissed
|
||||
</span>
|
||||
);
|
||||
case 'withdrawn':
|
||||
return (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-blue-500/20 text-blue-400 rounded-full">
|
||||
Withdrawn
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-6 bg-iron-gray rounded w-1/4" />
|
||||
<div className="h-48 bg-iron-gray rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stewardingData?.race) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Card className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="p-4 bg-warning-amber/10 rounded-full">
|
||||
<AlertTriangle className="w-8 h-8 text-warning-amber" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium mb-1">Race not found</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
The race you're looking for doesn't exist.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={onBack}>
|
||||
Back to Races
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: 'Races', href: '/races' },
|
||||
{ label: stewardingData.race.track, href: `/races/${stewardingData.race.id}` },
|
||||
{ label: 'Stewarding' },
|
||||
];
|
||||
|
||||
const pendingProtests = stewardingData.pendingProtests ?? [];
|
||||
const resolvedProtests = stewardingData.resolvedProtests ?? [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Breadcrumbs items={breadcrumbItems} className="text-sm text-gray-400" />
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onBack()}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Race
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<Card className="bg-gradient-to-r from-iron-gray/50 to-iron-gray/30">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary-blue/20 flex items-center justify-center">
|
||||
<Scale className="w-6 h-6 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Stewarding</h1>
|
||||
<p className="text-sm text-gray-400">
|
||||
{stewardingData.race.track} • {stewardingData.race.scheduledAt ? formatDate(stewardingData.race.scheduledAt) : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<RaceStewardingStats
|
||||
pendingCount={stewardingData.pendingCount ?? 0}
|
||||
resolvedCount={stewardingData.resolvedCount ?? 0}
|
||||
penaltiesCount={stewardingData.penaltiesCount ?? 0}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<StewardingTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
pendingCount={pendingProtests.length}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === 'pending' && (
|
||||
<div className="space-y-4">
|
||||
{pendingProtests.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
|
||||
<Flag className="w-8 h-8 text-performance-green" />
|
||||
</div>
|
||||
<p className="font-semibold text-lg text-white mb-2">All Clear!</p>
|
||||
<p className="text-sm text-gray-400">No pending protests to review</p>
|
||||
</Card>
|
||||
) : (
|
||||
pendingProtests.map((protest) => {
|
||||
const protester = stewardingData.driverMap[protest.protestingDriverId];
|
||||
const accused = stewardingData.driverMap[protest.accusedDriverId];
|
||||
const daysSinceFiled = Math.floor(
|
||||
(Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const isUrgent = daysSinceFiled > 2;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={protest.id}
|
||||
className={`${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" />
|
||||
<Link
|
||||
href={`/drivers/${protest.protestingDriverId}`}
|
||||
className="font-medium text-white hover:text-primary-blue transition-colors"
|
||||
>
|
||||
{protester?.name || 'Unknown'}
|
||||
</Link>
|
||||
<span className="text-gray-400">vs</span>
|
||||
<Link
|
||||
href={`/drivers/${protest.accusedDriverId}`}
|
||||
className="font-medium text-white hover:text-primary-blue transition-colors"
|
||||
>
|
||||
{accused?.name || 'Unknown'}
|
||||
</Link>
|
||||
{getStatusBadge(protest.status)}
|
||||
{isUrgent && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{daysSinceFiled}d old
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
|
||||
<span>Lap {protest.incident.lap}</span>
|
||||
<span>•</span>
|
||||
<span>Filed {formatDate(protest.filedAt)}</span>
|
||||
{protest.proofVideoUrl && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<a
|
||||
href={protest.proofVideoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-primary-blue hover:underline"
|
||||
>
|
||||
<Video className="w-3 h-3" />
|
||||
Video Evidence
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">{protest.incident.description}</p>
|
||||
</div>
|
||||
{isAdmin && stewardingData?.league && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => onReviewProtest(protest.id)}
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'resolved' && (
|
||||
<div className="space-y-4">
|
||||
{resolvedProtests.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-500/10 flex items-center justify-center">
|
||||
<CheckCircle className="w-8 h-8 text-gray-500" />
|
||||
</div>
|
||||
<p className="font-semibold text-lg text-white mb-2">No Resolved Protests</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Resolved protests will appear here
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
resolvedProtests.map((protest) => {
|
||||
const protester = stewardingData.driverMap[protest.protestingDriverId];
|
||||
const accused = stewardingData.driverMap[protest.accusedDriverId];
|
||||
|
||||
return (
|
||||
<Card key={protest.id}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
<Link
|
||||
href={`/drivers/${protest.protestingDriverId}`}
|
||||
className="font-medium text-white hover:text-primary-blue transition-colors"
|
||||
>
|
||||
{protester?.name || 'Unknown'}
|
||||
</Link>
|
||||
<span className="text-gray-400">vs</span>
|
||||
<Link
|
||||
href={`/drivers/${protest.accusedDriverId}`}
|
||||
className="font-medium text-white hover:text-primary-blue transition-colors"
|
||||
>
|
||||
{accused?.name || 'Unknown'}
|
||||
</Link>
|
||||
{getStatusBadge(protest.status)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
|
||||
<span>Lap {protest.incident.lap}</span>
|
||||
<span>•</span>
|
||||
<span>Filed {formatDate(protest.filedAt)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300 mb-2">
|
||||
{protest.incident.description}
|
||||
</p>
|
||||
{protest.decisionNotes && (
|
||||
<div className="mt-2 p-3 rounded bg-iron-gray/50 border border-charcoal-outline/50">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">
|
||||
Steward Decision
|
||||
</p>
|
||||
<p className="text-sm text-gray-300">{protest.decisionNotes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'penalties' && (
|
||||
<div className="space-y-4">
|
||||
{stewardingData?.penalties.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-500/10 flex items-center justify-center">
|
||||
<Gavel className="w-8 h-8 text-gray-500" />
|
||||
</div>
|
||||
<p className="font-semibold text-lg text-white mb-2">No Penalties</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Penalties issued for this race will appear here
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
stewardingData?.penalties.map((penalty) => {
|
||||
const driver = stewardingData.driverMap[penalty.driverId];
|
||||
return (
|
||||
<Card key={penalty.id}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Gavel className="w-6 h-6 text-red-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Link
|
||||
href={`/drivers/${penalty.driverId}`}
|
||||
className="font-medium text-white hover:text-primary-blue transition-colors"
|
||||
>
|
||||
{driver?.name || 'Unknown'}
|
||||
</Link>
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
|
||||
{penalty.type.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{penalty.reason}</p>
|
||||
{penalty.notes && (
|
||||
<p className="text-sm text-gray-500 mt-1 italic">{penalty.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-2xl font-bold text-red-400">
|
||||
{penalty.type === 'time_penalty' && `+${penalty.value}s`}
|
||||
{penalty.type === 'grid_penalty' && `+${penalty.value} grid`}
|
||||
{penalty.type === 'points_deduction' && `-${penalty.value} pts`}
|
||||
{penalty.type === 'disqualification' && 'DSQ'}
|
||||
{penalty.type === 'warning' && 'Warning'}
|
||||
{penalty.type === 'license_points' && `${penalty.value} LP`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
416
apps/website/templates/RacesAllTemplate.tsx
Normal file
416
apps/website/templates/RacesAllTemplate.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
Flag,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Car,
|
||||
Trophy,
|
||||
Zap,
|
||||
PlayCircle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { RaceFilterModal } from '@/components/races/RaceFilterModal';
|
||||
import { RacePagination } from '@/components/races/RacePagination';
|
||||
|
||||
export type StatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
|
||||
|
||||
export interface Race {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
sessionType: string;
|
||||
leagueId?: string;
|
||||
leagueName?: string;
|
||||
strengthOfField?: number | null;
|
||||
}
|
||||
|
||||
export interface RacesAllTemplateProps {
|
||||
races: Race[];
|
||||
isLoading: boolean;
|
||||
// Pagination
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
itemsPerPage: number;
|
||||
onPageChange: (page: number) => void;
|
||||
// Filters
|
||||
statusFilter: StatusFilter;
|
||||
setStatusFilter: (filter: StatusFilter) => void;
|
||||
leagueFilter: string;
|
||||
setLeagueFilter: (filter: string) => void;
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
// UI State
|
||||
showFilters: boolean;
|
||||
setShowFilters: (show: boolean) => void;
|
||||
showFilterModal: boolean;
|
||||
setShowFilterModal: (show: boolean) => void;
|
||||
// Actions
|
||||
onRaceClick: (raceId: string) => void;
|
||||
onLeagueClick: (leagueId: string) => void;
|
||||
}
|
||||
|
||||
export function RacesAllTemplate({
|
||||
races,
|
||||
isLoading,
|
||||
currentPage,
|
||||
totalPages,
|
||||
itemsPerPage,
|
||||
onPageChange,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
leagueFilter,
|
||||
setLeagueFilter,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
showFilters,
|
||||
setShowFilters,
|
||||
showFilterModal,
|
||||
setShowFilterModal,
|
||||
onRaceClick,
|
||||
onLeagueClick,
|
||||
}: RacesAllTemplateProps) {
|
||||
// Filter races
|
||||
const filteredRaces = useMemo(() => {
|
||||
return races.filter(race => {
|
||||
if (statusFilter !== 'all' && race.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const matchesTrack = race.track.toLowerCase().includes(query);
|
||||
const matchesCar = race.car.toLowerCase().includes(query);
|
||||
const matchesLeague = race.leagueName?.toLowerCase().includes(query);
|
||||
if (!matchesTrack && !matchesCar && !matchesLeague) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [races, statusFilter, leagueFilter, searchQuery]);
|
||||
|
||||
// Paginate
|
||||
const paginatedRaces = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return filteredRaces.slice(start, start + itemsPerPage);
|
||||
}, [filteredRaces, currentPage, itemsPerPage]);
|
||||
|
||||
// Reset page when filters change
|
||||
useEffect(() => {
|
||||
onPageChange(1);
|
||||
}, [statusFilter, leagueFilter, searchQuery]);
|
||||
|
||||
const formatDate = (date: Date | string) => {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (date: Date | string) => {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
scheduled: {
|
||||
icon: Clock,
|
||||
color: 'text-primary-blue',
|
||||
bg: 'bg-primary-blue/10',
|
||||
border: 'border-primary-blue/30',
|
||||
label: 'Scheduled',
|
||||
},
|
||||
running: {
|
||||
icon: PlayCircle,
|
||||
color: 'text-performance-green',
|
||||
bg: 'bg-performance-green/10',
|
||||
border: 'border-performance-green/30',
|
||||
label: 'LIVE',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-gray-400',
|
||||
bg: 'bg-gray-500/10',
|
||||
border: 'border-gray-500/30',
|
||||
label: 'Completed',
|
||||
},
|
||||
cancelled: {
|
||||
icon: XCircle,
|
||||
color: 'text-warning-amber',
|
||||
bg: 'bg-warning-amber/10',
|
||||
border: 'border-warning-amber/30',
|
||||
label: 'Cancelled',
|
||||
},
|
||||
};
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: 'Races', href: '/races' },
|
||||
{ label: 'All Races' },
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-6 bg-iron-gray rounded w-1/4" />
|
||||
<div className="h-10 bg-iron-gray rounded w-1/3" />
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="h-24 bg-iron-gray rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
{/* Breadcrumbs */}
|
||||
<Breadcrumbs items={breadcrumbItems} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<Heading level={1} className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<Flag className="w-6 h-6 text-primary-blue" />
|
||||
All Races
|
||||
</Heading>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
{filteredRaces.length} race{filteredRaces.length !== 1 ? 's' : ''} found
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
Filters
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search & Filters */}
|
||||
<Card className={`!p-4 ${showFilters ? '' : 'hidden sm:block'}`}>
|
||||
<div className="space-y-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search by track, car, or league..."
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter Row */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{/* Status Filter */}
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
|
||||
className="px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="running">Live</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
|
||||
{/* League Filter */}
|
||||
<select
|
||||
value={leagueFilter}
|
||||
onChange={(e) => setLeagueFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
>
|
||||
<option value="all">All Leagues</option>
|
||||
{races && [...new Set(races.map(r => r.leagueId))].filter(Boolean).map(leagueId => {
|
||||
const race = races.find(r => r.leagueId === leagueId);
|
||||
return race ? (
|
||||
<option key={leagueId} value={leagueId}>
|
||||
{race.leagueName}
|
||||
</option>
|
||||
) : null;
|
||||
})}
|
||||
</select>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{(statusFilter !== 'all' || leagueFilter !== 'all' || searchQuery) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setStatusFilter('all');
|
||||
setLeagueFilter('all');
|
||||
setSearchQuery('');
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-primary-blue hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Race List */}
|
||||
{paginatedRaces.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="p-4 bg-iron-gray rounded-full">
|
||||
<Calendar className="w-8 h-8 text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium mb-1">No races found</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{races.length === 0
|
||||
? 'No races have been scheduled yet'
|
||||
: 'Try adjusting your search or filters'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{paginatedRaces.map(race => {
|
||||
const config = statusConfig[race.status as keyof typeof statusConfig];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={race.id}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
className={`group relative overflow-hidden rounded-xl bg-iron-gray border ${config.border} p-4 cursor-pointer transition-all duration-200 hover:scale-[1.01] hover:border-primary-blue`}
|
||||
>
|
||||
{/* Live indicator */}
|
||||
{race.status === 'running' && (
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-performance-green via-performance-green/50 to-performance-green animate-pulse" />
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Date Column */}
|
||||
<div className="hidden sm:flex flex-col items-center min-w-[80px] text-center">
|
||||
<p className="text-xs text-gray-500 uppercase">
|
||||
{new Date(race.scheduledAt).toLocaleDateString('en-US', { month: 'short' })}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{new Date(race.scheduledAt).getDate()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatTime(race.scheduledAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="hidden sm:block w-px h-16 bg-charcoal-outline" />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-white truncate group-hover:text-primary-blue transition-colors">
|
||||
{race.track}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-1">
|
||||
<span className="flex items-center gap-1.5 text-sm text-gray-400">
|
||||
<Car className="w-3.5 h-3.5" />
|
||||
{race.car}
|
||||
</span>
|
||||
{race.strengthOfField && (
|
||||
<span className="flex items-center gap-1.5 text-sm text-warning-amber">
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
SOF {race.strengthOfField}
|
||||
</span>
|
||||
)}
|
||||
<span className="sm:hidden text-sm text-gray-500">
|
||||
{formatDate(race.scheduledAt)}
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href={`/leagues/${race.leagueId}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1.5 mt-2 text-sm text-primary-blue hover:underline"
|
||||
>
|
||||
<Trophy className="w-3.5 h-3.5" />
|
||||
{race.leagueName}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full ${config.bg} ${config.border} border flex-shrink-0`}>
|
||||
<StatusIcon className={`w-3.5 h-3.5 ${config.color}`} />
|
||||
<span className={`text-xs font-medium ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-primary-blue transition-colors flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
<RacePagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={filteredRaces.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
|
||||
{/* Filter Modal */}
|
||||
<RaceFilterModal
|
||||
isOpen={showFilterModal}
|
||||
onClose={() => setShowFilterModal(false)}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
leagueFilter={leagueFilter}
|
||||
setLeagueFilter={setLeagueFilter}
|
||||
timeFilter="all"
|
||||
setTimeFilter={() => {}}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
leagues={[...new Set(races.map(r => ({ id: r.leagueId || '', name: r.leagueName || '' })))]}
|
||||
showSearch={true}
|
||||
showTimeFilter={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
663
apps/website/templates/RacesTemplate.tsx
Normal file
663
apps/website/templates/RacesTemplate.tsx
Normal file
@@ -0,0 +1,663 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
Flag,
|
||||
ChevronRight,
|
||||
MapPin,
|
||||
Car,
|
||||
Trophy,
|
||||
Users,
|
||||
Zap,
|
||||
PlayCircle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
CalendarDays,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { RaceFilterModal } from '@/components/races/RaceFilterModal';
|
||||
import { RaceJoinButton } from '@/components/races/RaceJoinButton';
|
||||
|
||||
export type TimeFilter = 'all' | 'upcoming' | 'live' | 'past';
|
||||
export type RaceStatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
|
||||
|
||||
export interface Race {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
sessionType: string;
|
||||
leagueId?: string;
|
||||
leagueName?: string;
|
||||
strengthOfField?: number | null;
|
||||
isUpcoming: boolean;
|
||||
isLive: boolean;
|
||||
isPast: boolean;
|
||||
}
|
||||
|
||||
export interface RacesTemplateProps {
|
||||
races: Race[];
|
||||
totalCount: number;
|
||||
scheduledRaces: Race[];
|
||||
runningRaces: Race[];
|
||||
completedRaces: Race[];
|
||||
isLoading: boolean;
|
||||
// Filters
|
||||
statusFilter: RaceStatusFilter;
|
||||
setStatusFilter: (filter: RaceStatusFilter) => void;
|
||||
leagueFilter: string;
|
||||
setLeagueFilter: (filter: string) => void;
|
||||
timeFilter: TimeFilter;
|
||||
setTimeFilter: (filter: TimeFilter) => void;
|
||||
// Actions
|
||||
onRaceClick: (raceId: string) => void;
|
||||
onLeagueClick: (leagueId: string) => void;
|
||||
onRegister: (raceId: string, leagueId: string) => void;
|
||||
onWithdraw: (raceId: string) => void;
|
||||
onCancel: (raceId: string) => void;
|
||||
// UI State
|
||||
showFilterModal: boolean;
|
||||
setShowFilterModal: (show: boolean) => void;
|
||||
// User state
|
||||
currentDriverId?: string;
|
||||
userMemberships?: Array<{ leagueId: string; role: string }>;
|
||||
}
|
||||
|
||||
export function RacesTemplate({
|
||||
races,
|
||||
totalCount,
|
||||
scheduledRaces,
|
||||
runningRaces,
|
||||
completedRaces,
|
||||
isLoading,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
leagueFilter,
|
||||
setLeagueFilter,
|
||||
timeFilter,
|
||||
setTimeFilter,
|
||||
onRaceClick,
|
||||
onLeagueClick,
|
||||
onRegister,
|
||||
onWithdraw,
|
||||
onCancel,
|
||||
showFilterModal,
|
||||
setShowFilterModal,
|
||||
currentDriverId,
|
||||
userMemberships,
|
||||
}: RacesTemplateProps) {
|
||||
// Filter races
|
||||
const filteredRaces = useMemo(() => {
|
||||
return races.filter((race) => {
|
||||
// Status filter
|
||||
if (statusFilter !== 'all' && race.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// League filter
|
||||
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Time filter
|
||||
if (timeFilter === 'upcoming' && !race.isUpcoming) {
|
||||
return false;
|
||||
}
|
||||
if (timeFilter === 'live' && !race.isLive) {
|
||||
return false;
|
||||
}
|
||||
if (timeFilter === 'past' && !race.isPast) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [races, statusFilter, leagueFilter, timeFilter]);
|
||||
|
||||
// Group races by date for calendar view
|
||||
const racesByDate = useMemo(() => {
|
||||
const grouped = new Map<string, typeof filteredRaces[0][]>();
|
||||
filteredRaces.forEach((race) => {
|
||||
const dateKey = race.scheduledAt.split('T')[0]!;
|
||||
if (!grouped.has(dateKey)) {
|
||||
grouped.set(dateKey, []);
|
||||
}
|
||||
grouped.get(dateKey)!.push(race);
|
||||
});
|
||||
return grouped;
|
||||
}, [filteredRaces]);
|
||||
|
||||
const upcomingRaces = filteredRaces.filter(r => r.isUpcoming).slice(0, 5);
|
||||
const liveRaces = filteredRaces.filter(r => r.isLive);
|
||||
const recentResults = filteredRaces.filter(r => r.isPast).slice(0, 5);
|
||||
const stats = {
|
||||
total: totalCount,
|
||||
scheduled: scheduledRaces.length,
|
||||
running: runningRaces.length,
|
||||
completed: completedRaces.length,
|
||||
};
|
||||
|
||||
const formatDate = (date: Date | string) => {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (date: Date | string) => {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatFullDate = (date: Date | string) => {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const getRelativeTime = (date?: Date | string) => {
|
||||
if (!date) return '';
|
||||
const now = new Date();
|
||||
const targetDate = typeof date === 'string' ? new Date(date) : date;
|
||||
const diffMs = targetDate.getTime() - now.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMs < 0) return 'Past';
|
||||
if (diffHours < 1) return 'Starting soon';
|
||||
if (diffHours < 24) return `In ${diffHours}h`;
|
||||
if (diffDays === 1) return 'Tomorrow';
|
||||
if (diffDays < 7) return `In ${diffDays} days`;
|
||||
return formatDate(targetDate);
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
scheduled: {
|
||||
icon: Clock,
|
||||
color: 'text-primary-blue',
|
||||
bg: 'bg-primary-blue/10',
|
||||
border: 'border-primary-blue/30',
|
||||
label: 'Scheduled',
|
||||
},
|
||||
running: {
|
||||
icon: PlayCircle,
|
||||
color: 'text-performance-green',
|
||||
bg: 'bg-performance-green/10',
|
||||
border: 'border-performance-green/30',
|
||||
label: 'LIVE',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-gray-400',
|
||||
bg: 'bg-gray-500/10',
|
||||
border: 'border-gray-500/30',
|
||||
label: 'Completed',
|
||||
},
|
||||
cancelled: {
|
||||
icon: XCircle,
|
||||
color: 'text-warning-amber',
|
||||
bg: 'bg-warning-amber/10',
|
||||
border: 'border-warning-amber/30',
|
||||
label: 'Cancelled',
|
||||
},
|
||||
};
|
||||
|
||||
const isUserRegistered = (race: Race) => {
|
||||
// This would need actual registration data
|
||||
return false;
|
||||
};
|
||||
|
||||
const canRegister = (race: Race) => {
|
||||
// This would need actual registration rules
|
||||
return race.status === 'scheduled';
|
||||
};
|
||||
|
||||
const isOwnerOrAdmin = (leagueId?: string) => {
|
||||
if (!leagueId || !userMemberships) return false;
|
||||
const membership = userMemberships.find(m => m.leagueId === leagueId);
|
||||
return membership?.role === 'owner' || membership?.role === 'admin';
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-10 bg-iron-gray rounded w-1/4" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="h-24 bg-iron-gray rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
<div className="h-64 bg-iron-gray rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
{/* Hero Header */}
|
||||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-iron-gray via-iron-gray to-charcoal-outline border border-charcoal-outline p-8">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-primary-blue/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 left-0 w-48 h-48 bg-performance-green/5 rounded-full blur-3xl" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-primary-blue/10 rounded-lg">
|
||||
<Flag className="w-6 h-6 text-primary-blue" />
|
||||
</div>
|
||||
<Heading level={1} className="text-3xl font-bold text-white">
|
||||
Race Calendar
|
||||
</Heading>
|
||||
</div>
|
||||
<p className="text-gray-400 max-w-2xl">
|
||||
Track upcoming races, view live events, and explore results across all your leagues.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="relative z-10 grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||||
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1">
|
||||
<CalendarDays className="w-4 h-4" />
|
||||
<span>Total</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{stats.total}</p>
|
||||
</div>
|
||||
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
|
||||
<div className="flex items-center gap-2 text-primary-blue text-sm mb-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>Scheduled</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{stats.scheduled}</p>
|
||||
</div>
|
||||
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
|
||||
<div className="flex items-center gap-2 text-performance-green text-sm mb-1">
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>Live Now</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{stats.running}</p>
|
||||
</div>
|
||||
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1">
|
||||
<Trophy className="w-4 h-4" />
|
||||
<span>Completed</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{stats.completed}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Races Banner */}
|
||||
{liveRaces.length > 0 && (
|
||||
<div className="relative overflow-hidden rounded-xl bg-gradient-to-r from-performance-green/20 via-performance-green/10 to-transparent border border-performance-green/30 p-6">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-performance-green/20 rounded-full blur-2xl animate-pulse" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-performance-green/20 rounded-full">
|
||||
<span className="w-2 h-2 bg-performance-green rounded-full animate-pulse" />
|
||||
<span className="text-performance-green font-semibold text-sm">LIVE NOW</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{liveRaces.map((race) => (
|
||||
<div
|
||||
key={race.id}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
className="flex items-center justify-between p-4 bg-deep-graphite/80 rounded-lg border border-performance-green/20 cursor-pointer hover:border-performance-green/40 transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-performance-green/20 rounded-lg">
|
||||
<PlayCircle className="w-5 h-5 text-performance-green" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">{race.track}</h3>
|
||||
<p className="text-sm text-gray-400">{race.leagueName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Content - Race List */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Filters */}
|
||||
<Card className="!p-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{/* Time Filter Tabs */}
|
||||
<div className="flex items-center gap-1 p-1 bg-deep-graphite rounded-lg">
|
||||
{(['upcoming', 'live', 'past', 'all'] as TimeFilter[]).map(filter => (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setTimeFilter(filter)}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
timeFilter === filter
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{filter === 'live' && <span className="inline-block w-2 h-2 bg-performance-green rounded-full mr-2 animate-pulse" />}
|
||||
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* League Filter */}
|
||||
<select
|
||||
value={leagueFilter}
|
||||
onChange={(e) => setLeagueFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
>
|
||||
<option value="all">All Leagues</option>
|
||||
{races && [...new Set(races.map(r => r.leagueId))].filter(Boolean).map(leagueId => {
|
||||
const item = races.find(r => r.leagueId === leagueId);
|
||||
return item ? (
|
||||
<option key={leagueId} value={leagueId}>
|
||||
{item.leagueName}
|
||||
</option>
|
||||
) : null;
|
||||
})}
|
||||
</select>
|
||||
|
||||
{/* Filter Button */}
|
||||
<button
|
||||
onClick={() => setShowFilterModal(true)}
|
||||
className="px-4 py-2 bg-iron-gray border border-charcoal-outline rounded-lg text-white text-sm hover:border-primary-blue transition-colors"
|
||||
>
|
||||
More Filters
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Race List by Date */}
|
||||
{filteredRaces.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="p-4 bg-iron-gray rounded-full">
|
||||
<Calendar className="w-8 h-8 text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium mb-1">No races found</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{totalCount === 0
|
||||
? 'No races have been scheduled yet'
|
||||
: 'Try adjusting your filters'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Array.from(racesByDate.entries()).map(([dateKey, dayRaces]) => (
|
||||
<div key={dateKey} className="space-y-3">
|
||||
{/* Date Header */}
|
||||
<div className="flex items-center gap-3 px-2">
|
||||
<div className="p-2 bg-primary-blue/10 rounded-lg">
|
||||
<Calendar className="w-4 h-4 text-primary-blue" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{formatFullDate(new Date(dateKey))}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{dayRaces.length} race{dayRaces.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Races for this date */}
|
||||
<div className="space-y-2">
|
||||
{dayRaces.map((race) => {
|
||||
const config = statusConfig[race.status as keyof typeof statusConfig];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={race.id}
|
||||
className={`group relative overflow-hidden rounded-xl bg-iron-gray border ${config.border} p-4 cursor-pointer transition-all duration-200 hover:scale-[1.01] hover:border-primary-blue`}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
>
|
||||
{/* Live indicator */}
|
||||
{race.status === 'running' && (
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-performance-green via-performance-green/50 to-performance-green animate-pulse" />
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Time Column */}
|
||||
<div className="flex-shrink-0 text-center min-w-[60px]">
|
||||
<p className="text-lg font-bold text-white">
|
||||
{formatTime(race.scheduledAt)}
|
||||
</p>
|
||||
<p className={`text-xs ${config.color}`}>
|
||||
{race.status === 'running'
|
||||
? 'LIVE'
|
||||
: getRelativeTime(race.scheduledAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className={`w-px self-stretch ${config.bg}`} />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-white truncate group-hover:text-primary-blue transition-colors">
|
||||
{race.track}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="flex items-center gap-1 text-sm text-gray-400">
|
||||
<Car className="w-3.5 h-3.5" />
|
||||
{race.car}
|
||||
</span>
|
||||
{race.strengthOfField && (
|
||||
<span className="flex items-center gap-1 text-sm text-gray-400">
|
||||
<Zap className="w-3.5 h-3.5 text-warning-amber" />
|
||||
SOF {race.strengthOfField}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full ${config.bg} ${config.border} border`}>
|
||||
<StatusIcon className={`w-3.5 h-3.5 ${config.color}`} />
|
||||
<span className={`text-xs font-medium ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* League Link */}
|
||||
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
|
||||
<Link
|
||||
href={`/leagues/${race.leagueId ?? ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-2 text-sm text-primary-blue hover:underline"
|
||||
>
|
||||
<Trophy className="w-3.5 h-3.5" />
|
||||
{race.leagueName}
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-primary-blue transition-colors flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View All Link */}
|
||||
{filteredRaces.length > 0 && (
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/races/all"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-iron-gray border border-charcoal-outline rounded-lg text-white hover:border-primary-blue transition-colors"
|
||||
>
|
||||
View All Races
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Upcoming This Week */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-white flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-primary-blue" />
|
||||
Next Up
|
||||
</h3>
|
||||
<span className="text-xs text-gray-500">This week</span>
|
||||
</div>
|
||||
|
||||
{upcomingRaces.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4">
|
||||
No races scheduled this week
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{upcomingRaces.map((race) => {
|
||||
if (!race.scheduledAt) {
|
||||
return null;
|
||||
}
|
||||
const scheduledAtDate = new Date(race.scheduledAt);
|
||||
return (
|
||||
<div
|
||||
key={race.id}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-primary-blue/10 rounded-lg flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-primary-blue">
|
||||
{scheduledAtDate.getDate()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white truncate">{race.track}</p>
|
||||
<p className="text-xs text-gray-500">{formatTime(scheduledAtDate)}</p>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Recent Results */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-white flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4 text-warning-amber" />
|
||||
Recent Results
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{recentResults.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4">
|
||||
No completed races yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentResults.map((race) => (
|
||||
<div
|
||||
key={race.id}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-gray-500/10 rounded-lg flex items-center justify-center">
|
||||
<CheckCircle2 className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white truncate">{race.track}</p>
|
||||
<p className="text-xs text-gray-500">{formatDate(new Date(race.scheduledAt))}</p>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<h3 className="font-semibold text-white mb-4">Quick Actions</h3>
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href="/leagues"
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite hover:bg-charcoal-outline/50 transition-colors"
|
||||
>
|
||||
<div className="p-2 bg-primary-blue/10 rounded-lg">
|
||||
<Users className="w-4 h-4 text-primary-blue" />
|
||||
</div>
|
||||
<span className="text-sm text-white">Browse Leagues</span>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500 ml-auto" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/leaderboards"
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite hover:bg-charcoal-outline/50 transition-colors"
|
||||
>
|
||||
<div className="p-2 bg-warning-amber/10 rounded-lg">
|
||||
<Trophy className="w-4 h-4 text-warning-amber" />
|
||||
</div>
|
||||
<span className="text-sm text-white">View Leaderboards</span>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500 ml-auto" />
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Modal */}
|
||||
<RaceFilterModal
|
||||
isOpen={showFilterModal}
|
||||
onClose={() => setShowFilterModal(false)}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
leagueFilter={leagueFilter}
|
||||
setLeagueFilter={setLeagueFilter}
|
||||
timeFilter={timeFilter}
|
||||
setTimeFilter={setTimeFilter}
|
||||
searchQuery=""
|
||||
setSearchQuery={() => {}}
|
||||
leagues={[...new Set(races.map(r => ({ id: r.leagueId || '', name: r.leagueName || '' })))]}
|
||||
showSearch={false}
|
||||
showTimeFilter={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
apps/website/templates/TeamDetailTemplate.tsx
Normal file
267
apps/website/templates/TeamDetailTemplate.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
'use client';
|
||||
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import SponsorInsightsCard, { MetricBuilders, SlotTemplates, useSponsorMode } from '@/components/sponsors/SponsorInsightsCard';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Image from 'next/image';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import JoinTeamButton from '@/components/teams/JoinTeamButton';
|
||||
import TeamAdmin from '@/components/teams/TeamAdmin';
|
||||
import TeamRoster from '@/components/teams/TeamRoster';
|
||||
import TeamStandings from '@/components/teams/TeamStandings';
|
||||
import StatItem from '@/components/teams/StatItem';
|
||||
import type { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
|
||||
import type { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
import PlaceholderImage from '@/components/ui/PlaceholderImage';
|
||||
|
||||
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
|
||||
|
||||
// ============================================================================
|
||||
// TEMPLATE PROPS
|
||||
// ============================================================================
|
||||
|
||||
export interface TeamDetailTemplateProps {
|
||||
// Data props
|
||||
team: TeamDetailsViewModel | null;
|
||||
memberships: TeamMemberViewModel[];
|
||||
activeTab: Tab;
|
||||
loading: boolean;
|
||||
isAdmin: boolean;
|
||||
|
||||
// Event handlers
|
||||
onTabChange: (tab: Tab) => void;
|
||||
onUpdate: () => void;
|
||||
onRemoveMember: (driverId: string) => void;
|
||||
onChangeRole: (driverId: string, newRole: 'owner' | 'admin' | 'member') => void;
|
||||
onGoBack: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN TEMPLATE COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export default function TeamDetailTemplate({
|
||||
team,
|
||||
memberships,
|
||||
activeTab,
|
||||
loading,
|
||||
isAdmin,
|
||||
onTabChange,
|
||||
onUpdate,
|
||||
onRemoveMember,
|
||||
onChangeRole,
|
||||
onGoBack,
|
||||
}: TeamDetailTemplateProps) {
|
||||
const isSponsorMode = useSponsorMode();
|
||||
|
||||
// Show loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center text-gray-400">Loading team...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show not found state
|
||||
if (!team) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Team Not Found</h2>
|
||||
<p className="text-gray-400 mb-6">
|
||||
The team you're looking for doesn't exist or has been disbanded.
|
||||
</p>
|
||||
<Button variant="primary" onClick={onGoBack}>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs: { id: Tab; label: string; visible: boolean }[] = [
|
||||
{ id: 'overview', label: 'Overview', visible: true },
|
||||
{ id: 'roster', label: 'Roster', visible: true },
|
||||
{ id: 'standings', label: 'Standings', visible: true },
|
||||
{ id: 'admin', label: 'Admin', visible: isAdmin },
|
||||
];
|
||||
|
||||
const visibleTabs = tabs.filter(tab => tab.visible);
|
||||
|
||||
// Build sponsor insights for team using real membership and league data
|
||||
const leagueCount = team.leagues?.length ?? 0;
|
||||
const teamMetrics = [
|
||||
MetricBuilders.members(memberships.length),
|
||||
MetricBuilders.reach(memberships.length * 15),
|
||||
MetricBuilders.races(leagueCount),
|
||||
MetricBuilders.engagement(82),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Teams', href: '/teams' },
|
||||
{ label: team.name }
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Sponsor Insights Card - Consistent placement at top */}
|
||||
{isSponsorMode && team && (
|
||||
<SponsorInsightsCard
|
||||
entityType="team"
|
||||
entityId={team.id}
|
||||
entityName={team.name}
|
||||
tier="standard"
|
||||
metrics={teamMetrics}
|
||||
slots={SlotTemplates.team(true, true, 500, 250)}
|
||||
trustScore={90}
|
||||
monthlyActivity={85}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card className="mb-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-6">
|
||||
<div className="w-24 h-24 bg-charcoal-outline rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||
<Image
|
||||
src={getMediaUrl('team-logo', team.id)}
|
||||
alt={team.name}
|
||||
width={96}
|
||||
height={96}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl font-bold text-white">{team.name}</h1>
|
||||
{team.tag && (
|
||||
<span className="px-2 py-0.5 rounded-full text-xs bg-charcoal-outline text-gray-300">
|
||||
[{team.tag}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-gray-300 mb-4 max-w-2xl">{team.description}</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400">
|
||||
<span>{memberships.length} {memberships.length === 1 ? 'member' : 'members'}</span>
|
||||
{team.category && (
|
||||
<span className="flex items-center gap-1 text-purple-400">
|
||||
<span className="w-2 h-2 rounded-full bg-purple-400"></span>
|
||||
{team.category}
|
||||
</span>
|
||||
)}
|
||||
{team.createdAt && (
|
||||
<span>
|
||||
Founded {new Date(team.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
||||
</span>
|
||||
)}
|
||||
{leagueCount > 0 && (
|
||||
<span>
|
||||
Active in {leagueCount} {leagueCount === 1 ? 'league' : 'leagues'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JoinTeamButton teamId={team.id} onUpdate={onUpdate} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 border-b border-charcoal-outline">
|
||||
{visibleTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`
|
||||
px-4 py-3 font-medium transition-all relative
|
||||
${activeTab === tab.id
|
||||
? 'text-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tab.label}
|
||||
{activeTab === tab.id && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-blue" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-2">
|
||||
<h3 className="text-xl font-semibold text-white mb-4">About</h3>
|
||||
<p className="text-gray-300 leading-relaxed">{team.description}</p>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-xl font-semibold text-white mb-4">Quick Stats</h3>
|
||||
<div className="space-y-3">
|
||||
<StatItem label="Members" value={memberships.length.toString()} color="text-primary-blue" />
|
||||
{team.category && (
|
||||
<StatItem label="Category" value={team.category} color="text-purple-400" />
|
||||
)}
|
||||
{leagueCount > 0 && (
|
||||
<StatItem label="Leagues" value={leagueCount.toString()} color="text-green-400" />
|
||||
)}
|
||||
{team.createdAt && (
|
||||
<StatItem
|
||||
label="Founded"
|
||||
value={new Date(team.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
color="text-gray-300"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-xl font-semibold text-white mb-4">Recent Activity</h3>
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
No recent activity to display
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'roster' && (
|
||||
<TeamRoster
|
||||
teamId={team.id}
|
||||
memberships={memberships}
|
||||
isAdmin={isAdmin}
|
||||
onRemoveMember={onRemoveMember}
|
||||
onChangeRole={onChangeRole}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'standings' && (
|
||||
<TeamStandings teamId={team.id} leagues={team.leagues} />
|
||||
)}
|
||||
|
||||
{activeTab === 'admin' && isAdmin && (
|
||||
<TeamAdmin team={team} onUpdate={onUpdate} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
374
apps/website/templates/TeamLeaderboardTemplate.tsx
Normal file
374
apps/website/templates/TeamLeaderboardTemplate.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
346
apps/website/templates/TeamsTemplate.tsx
Normal file
346
apps/website/templates/TeamsTemplate.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Users,
|
||||
Trophy,
|
||||
Search,
|
||||
Plus,
|
||||
Sparkles,
|
||||
Crown,
|
||||
Star,
|
||||
TrendingUp,
|
||||
Shield,
|
||||
Zap,
|
||||
UserPlus,
|
||||
ChevronRight,
|
||||
Timer,
|
||||
Target,
|
||||
Award,
|
||||
Handshake,
|
||||
MessageCircle,
|
||||
Calendar,
|
||||
} from 'lucide-react';
|
||||
import TeamCard from '@/components/teams/TeamCard';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import CreateTeamForm from '@/components/teams/CreateTeamForm';
|
||||
import WhyJoinTeamSection from '@/components/teams/WhyJoinTeamSection';
|
||||
import SkillLevelSection from '@/components/teams/SkillLevelSection';
|
||||
import FeaturedRecruiting from '@/components/teams/FeaturedRecruiting';
|
||||
import TeamLeaderboardPreview from '@/components/teams/TeamLeaderboardPreview';
|
||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
type TeamDisplayData = TeamSummaryViewModel;
|
||||
|
||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||
|
||||
// ============================================================================
|
||||
// SKILL LEVEL CONFIG
|
||||
// ============================================================================
|
||||
|
||||
const SKILL_LEVELS: {
|
||||
id: SkillLevel;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
id: 'pro',
|
||||
label: 'Pro',
|
||||
icon: Crown,
|
||||
color: 'text-yellow-400',
|
||||
bgColor: 'bg-yellow-400/10',
|
||||
borderColor: 'border-yellow-400/30',
|
||||
description: 'Elite competition, sponsored teams',
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
label: 'Advanced',
|
||||
icon: Star,
|
||||
color: 'text-purple-400',
|
||||
bgColor: 'bg-purple-400/10',
|
||||
borderColor: 'border-purple-400/30',
|
||||
description: 'Competitive racing, high consistency',
|
||||
},
|
||||
{
|
||||
id: 'intermediate',
|
||||
label: 'Intermediate',
|
||||
icon: TrendingUp,
|
||||
color: 'text-primary-blue',
|
||||
bgColor: 'bg-primary-blue/10',
|
||||
borderColor: 'border-primary-blue/30',
|
||||
description: 'Growing skills, regular practice',
|
||||
},
|
||||
{
|
||||
id: 'beginner',
|
||||
label: 'Beginner',
|
||||
icon: Shield,
|
||||
color: 'text-green-400',
|
||||
bgColor: 'bg-green-400/10',
|
||||
borderColor: 'border-green-400/30',
|
||||
description: 'Learning the basics, friendly environment',
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// TEMPLATE PROPS
|
||||
// ============================================================================
|
||||
|
||||
export interface TeamsTemplateProps {
|
||||
// Data props
|
||||
teams: TeamDisplayData[];
|
||||
isLoading?: boolean;
|
||||
|
||||
// UI state props
|
||||
searchQuery: string;
|
||||
showCreateForm: boolean;
|
||||
|
||||
// Derived data props
|
||||
teamsByLevel: Record<string, TeamDisplayData[]>;
|
||||
topTeams: TeamDisplayData[];
|
||||
recruitingCount: number;
|
||||
filteredTeams: TeamDisplayData[];
|
||||
|
||||
// Event handlers
|
||||
onSearchChange: (query: string) => void;
|
||||
onShowCreateForm: () => void;
|
||||
onHideCreateForm: () => void;
|
||||
onTeamClick: (teamId: string) => void;
|
||||
onCreateSuccess: (teamId: string) => void;
|
||||
onBrowseTeams: () => void;
|
||||
onSkillLevelClick: (level: SkillLevel) => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN TEMPLATE COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export default function TeamsTemplate({
|
||||
teams,
|
||||
isLoading = false,
|
||||
searchQuery,
|
||||
showCreateForm,
|
||||
teamsByLevel,
|
||||
topTeams,
|
||||
recruitingCount,
|
||||
filteredTeams,
|
||||
onSearchChange,
|
||||
onShowCreateForm,
|
||||
onHideCreateForm,
|
||||
onTeamClick,
|
||||
onCreateSuccess,
|
||||
onBrowseTeams,
|
||||
onSkillLevelClick,
|
||||
}: TeamsTemplateProps) {
|
||||
// Show create form view
|
||||
if (showCreateForm) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div className="mb-6">
|
||||
<Button variant="secondary" onClick={onHideCreateForm}>
|
||||
← Back to Teams
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<h2 className="text-2xl font-bold text-white mb-6">Create New Team</h2>
|
||||
<CreateTeamForm onCancel={onHideCreateForm} onSuccess={onCreateSuccess} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-2 border-purple-400 border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-gray-400">Loading teams...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 pb-12">
|
||||
{/* Hero Section - Different from Leagues */}
|
||||
<div className="relative mb-10 overflow-hidden">
|
||||
{/* Main Hero Card */}
|
||||
<div className="relative py-12 px-8 rounded-2xl bg-gradient-to-br from-purple-900/30 via-iron-gray/80 to-deep-graphite border border-purple-500/20">
|
||||
{/* Background decorations */}
|
||||
<div className="absolute top-0 right-0 w-80 h-80 bg-purple-500/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 left-1/4 w-64 h-64 bg-neon-aqua/5 rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 right-1/4 w-48 h-48 bg-yellow-400/5 rounded-full blur-2xl" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-8">
|
||||
<div className="max-w-xl">
|
||||
{/* Badge */}
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-xs font-medium mb-4">
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
Team Racing
|
||||
</div>
|
||||
|
||||
<Heading level={1} className="text-4xl lg:text-5xl mb-4">
|
||||
Find Your
|
||||
<span className="text-purple-400"> Crew</span>
|
||||
</Heading>
|
||||
|
||||
<p className="text-gray-400 text-lg leading-relaxed mb-6">
|
||||
Solo racing is great. Team racing is unforgettable. Join a team that matches your skill level and ambitions.
|
||||
</p>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="flex flex-wrap gap-4 mb-6">
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
|
||||
<Users className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-white font-semibold">{teams.length}</span>
|
||||
<span className="text-gray-500 text-sm">Teams</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
|
||||
<UserPlus className="w-4 h-4 text-performance-green" />
|
||||
<span className="text-white font-semibold">{recruitingCount}</span>
|
||||
<span className="text-gray-500 text-sm">Recruiting</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onShowCreateForm}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-purple-600 hover:bg-purple-500"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Team
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBrowseTeams}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
Browse Teams
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skill Level Quick Nav */}
|
||||
<div className="lg:w-72">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-3">Find Your Level</p>
|
||||
<div className="space-y-2">
|
||||
{SKILL_LEVELS.map((level) => {
|
||||
const LevelIcon = level.icon;
|
||||
const count = teamsByLevel[level.id]?.length || 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={level.id}
|
||||
type="button"
|
||||
onClick={() => onSkillLevelClick(level.id)}
|
||||
className={`w-full flex items-center justify-between p-3 rounded-lg ${level.bgColor} border ${level.borderColor} hover:scale-[1.02] transition-all duration-200`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<LevelIcon className={`w-4 h-4 ${level.color}`} />
|
||||
<span className="text-white font-medium">{level.label}</span>
|
||||
</div>
|
||||
<span className="text-gray-400 text-sm">{count} teams</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter Bar - Same style as Leagues */}
|
||||
<div id="teams-list" className="mb-6 scroll-mt-8">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search teams by name, description, region, or language..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Why Join Section */}
|
||||
{!searchQuery && <WhyJoinTeamSection />}
|
||||
|
||||
{/* Team Leaderboard Preview */}
|
||||
{!searchQuery && <TeamLeaderboardPreview topTeams={topTeams} onTeamClick={onTeamClick} />}
|
||||
|
||||
{/* Featured Recruiting */}
|
||||
{!searchQuery && <FeaturedRecruiting teams={teams} onTeamClick={onTeamClick} />}
|
||||
|
||||
{/* Teams by Skill Level */}
|
||||
{teams.length === 0 ? (
|
||||
<Card className="text-center py-16">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-2xl bg-purple-500/10 border border-purple-500/20 mb-6">
|
||||
<Users className="w-8 h-8 text-purple-400" />
|
||||
</div>
|
||||
<Heading level={2} className="text-2xl mb-3">
|
||||
No teams yet
|
||||
</Heading>
|
||||
<p className="text-gray-400 mb-8">
|
||||
Be the first to create a racing team. Gather drivers and compete together in endurance events.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onShowCreateForm}
|
||||
className="flex items-center gap-2 mx-auto bg-purple-600 hover:bg-purple-500"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Create Your First Team
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : filteredTeams.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Search className="w-10 h-10 text-gray-600" />
|
||||
<p className="text-gray-400">No teams found matching "{searchQuery}"</p>
|
||||
<Button variant="secondary" onClick={() => onSearchChange('')}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div>
|
||||
{SKILL_LEVELS.map((level, index) => (
|
||||
<div key={level.id} id={`level-${level.id}`} className="scroll-mt-8">
|
||||
<SkillLevelSection
|
||||
level={level}
|
||||
teams={teamsByLevel[level.id] ?? []}
|
||||
onTeamClick={onTeamClick}
|
||||
defaultExpanded={index === 0}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user