Files
gridpilot.gg/apps/website/app/leagues/page.tsx
2025-12-07 00:18:02 +01:00

1032 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import {
Trophy,
Users,
Globe,
Award,
Search,
Plus,
ChevronLeft,
ChevronRight,
Sparkles,
Flag,
Filter,
Gamepad2,
Flame,
Clock,
Zap,
Target,
Star,
TrendingUp,
Calendar,
Timer,
Car,
} 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 { LeagueSummaryDTO } from '@gridpilot/racing/application/dto/LeagueSummaryDTO';
import { getGetAllLeaguesWithCapacityAndScoringQuery } from '@/lib/di-container';
// ============================================================================
// 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: LeagueSummaryDTO) => boolean;
color?: string;
}
// ============================================================================
// DEMO LEAGUES DATA
// ============================================================================
const DEMO_LEAGUES: LeagueSummaryDTO[] = [
// Driver Championships
{
id: 'demo-1',
name: 'iRacing GT3 Pro Series',
description: 'Elite GT3 competition for serious sim racers. Weekly races on iconic tracks with professional stewarding and live commentary.',
createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), // 2 days ago
ownerId: 'owner-1',
maxDrivers: 32,
usedDriverSlots: 28,
structureSummary: 'Solo • 32 drivers',
scoringPatternSummary: 'Sprint + Main • Best 8 of 10',
timingSummary: '20 min Quali • 45 min Race',
scoring: {
gameId: 'iracing',
gameName: 'iRacing',
primaryChampionshipType: 'driver',
scoringPresetId: 'sprint-main-driver',
scoringPresetName: 'Sprint + Main (Driver)',
dropPolicySummary: 'Best 8 of 10',
scoringPatternSummary: 'Sprint + Main • Best 8 of 10',
},
},
{
id: 'demo-2',
name: 'iRacing IMSA Championship',
description: 'Race across continents in the most prestigious GT championship. Professional-grade competition with real-world rules.',
createdAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000),
ownerId: 'owner-2',
maxDrivers: 40,
usedDriverSlots: 35,
structureSummary: 'Solo • 40 drivers',
scoringPatternSummary: 'Feature Race • Best 6 of 8',
timingSummary: '30 min Quali • 60 min Race',
scoring: {
gameId: 'iracing',
gameName: 'iRacing',
primaryChampionshipType: 'driver',
scoringPresetId: 'feature-driver',
scoringPresetName: 'Feature Race (Driver)',
dropPolicySummary: 'Best 6 of 8',
scoringPatternSummary: 'Feature Race • Best 6 of 8',
},
},
{
id: 'demo-3',
name: 'iRacing Formula Championship',
description: 'The ultimate open-wheel experience. Full calendar, realistic regulations, and championship-level competition.',
createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), // Yesterday
ownerId: 'owner-3',
maxDrivers: 20,
usedDriverSlots: 20,
structureSummary: 'Solo • 20 drivers',
scoringPatternSummary: 'Sprint + Feature • All rounds count',
timingSummary: '18 min Quali • 50% Race',
scoring: {
gameId: 'iracing',
gameName: 'iRacing',
primaryChampionshipType: 'driver',
scoringPresetId: 'sprint-feature-driver',
scoringPresetName: 'Sprint + Feature (Driver)',
dropPolicySummary: 'All rounds count',
scoringPatternSummary: 'Sprint + Feature • All rounds count',
},
},
// Team Championships
{
id: 'demo-4',
name: 'Le Mans Virtual Series',
description: 'Endurance racing at its finest. Multi-class prototype and GT competition with team strategy at the core.',
createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000),
ownerId: 'owner-4',
maxDrivers: 48,
usedDriverSlots: 42,
maxTeams: 16,
usedTeamSlots: 14,
structureSummary: 'Teams • 16 × 3 drivers',
scoringPatternSummary: 'Endurance • Best 4 of 6',
timingSummary: '30 min Quali • 6h Race',
scoring: {
gameId: 'iracing',
gameName: 'iRacing',
primaryChampionshipType: 'team',
scoringPresetId: 'endurance-team',
scoringPresetName: 'Endurance (Team)',
dropPolicySummary: 'Best 4 of 6',
scoringPatternSummary: 'Endurance • Best 4 of 6',
},
},
{
id: 'demo-5',
name: 'iRacing British GT Teams',
description: 'British GT-style team championship. Pro-Am format with driver ratings and team strategy.',
createdAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000),
ownerId: 'owner-5',
maxDrivers: 40,
usedDriverSlots: 32,
maxTeams: 20,
usedTeamSlots: 16,
structureSummary: 'Teams • 20 × 2 drivers',
scoringPatternSummary: 'Sprint + Main • Best 8 of 10',
timingSummary: '15 min Quali • 60 min Race',
scoring: {
gameId: 'iracing',
gameName: 'iRacing',
primaryChampionshipType: 'team',
scoringPresetId: 'sprint-main-team',
scoringPresetName: 'Sprint + Main (Team)',
dropPolicySummary: 'Best 8 of 10',
scoringPatternSummary: 'Sprint + Main • Best 8 of 10',
},
},
// Nations Cup
{
id: 'demo-6',
name: 'FIA Nations Cup iRacing',
description: 'Represent your nation in this prestigious international competition. Pride, glory, and national anthems.',
createdAt: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000),
ownerId: 'owner-6',
maxDrivers: 50,
usedDriverSlots: 45,
structureSummary: 'Nations • 50 drivers',
scoringPatternSummary: 'Feature Race • All rounds count',
timingSummary: '20 min Quali • 40 min Race',
scoring: {
gameId: 'iracing',
gameName: 'iRacing',
primaryChampionshipType: 'nations',
scoringPresetId: 'feature-nations',
scoringPresetName: 'Feature Race (Nations)',
dropPolicySummary: 'All rounds count',
scoringPatternSummary: 'Feature Race • All rounds count',
},
},
{
id: 'demo-7',
name: 'European Nations GT Cup',
description: 'The best European nations battle it out in GT3 machinery. Honor your flag.',
createdAt: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000),
ownerId: 'owner-7',
maxDrivers: 30,
usedDriverSlots: 24,
structureSummary: 'Nations • 30 drivers',
scoringPatternSummary: 'Sprint + Main • Best 6 of 8',
timingSummary: '15 min Quali • 45 min Race',
scoring: {
gameId: 'iracing',
gameName: 'iRacing',
primaryChampionshipType: 'nations',
scoringPresetId: 'sprint-main-nations',
scoringPresetName: 'Sprint + Main (Nations)',
dropPolicySummary: 'Best 6 of 8',
scoringPatternSummary: 'Sprint + Main • Best 6 of 8',
},
},
// Trophy Series
{
id: 'demo-8',
name: 'Rookie Trophy Challenge',
description: 'Perfect for newcomers! Learn the ropes of competitive racing in a supportive environment.',
createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), // Yesterday
ownerId: 'owner-8',
maxDrivers: 24,
usedDriverSlots: 18,
structureSummary: 'Solo • 24 drivers',
scoringPatternSummary: 'Feature Race • Best 8 of 10',
timingSummary: '10 min Quali • 20 min Race',
scoring: {
gameId: 'iracing',
gameName: 'iRacing',
primaryChampionshipType: 'trophy',
scoringPresetId: 'feature-trophy',
scoringPresetName: 'Feature Race (Trophy)',
dropPolicySummary: 'Best 8 of 10',
scoringPatternSummary: 'Feature Race • Best 8 of 10',
},
},
{
id: 'demo-9',
name: 'Porsche Cup Masters',
description: 'One-make series featuring the iconic Porsche 911 GT3 Cup. Pure driving skill determines the winner.',
createdAt: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000),
ownerId: 'owner-9',
maxDrivers: 28,
usedDriverSlots: 26,
structureSummary: 'Solo • 28 drivers',
scoringPatternSummary: 'Sprint + Main • Best 10 of 12',
timingSummary: '15 min Quali • 30 min Race',
scoring: {
gameId: 'iracing',
gameName: 'iRacing',
primaryChampionshipType: 'trophy',
scoringPresetId: 'sprint-main-trophy',
scoringPresetName: 'Sprint + Main (Trophy)',
dropPolicySummary: 'Best 10 of 12',
scoringPatternSummary: 'Sprint + Main • Best 10 of 12',
},
},
// More variety - Recently Added
{
id: 'demo-10',
name: 'GT World Challenge Sprint',
description: 'Fast-paced sprint racing in GT3 machinery. Short, intense races that reward consistency.',
createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000), // 12 hours ago
ownerId: 'owner-10',
maxDrivers: 36,
usedDriverSlots: 12,
structureSummary: 'Solo • 36 drivers',
scoringPatternSummary: 'Sprint Format • Best 8 of 10',
timingSummary: '10 min Quali • 25 min Race',
scoring: {
gameId: 'iracing',
gameName: 'iRacing',
primaryChampionshipType: 'driver',
scoringPresetId: 'sprint-driver',
scoringPresetName: 'Sprint (Driver)',
dropPolicySummary: 'Best 8 of 10',
scoringPatternSummary: 'Sprint Format • Best 8 of 10',
},
},
{
id: 'demo-11',
name: 'Nürburgring 24h League',
description: 'The ultimate test of endurance. Teams battle through day and night at the legendary Nordschleife.',
createdAt: new Date(Date.now() - 6 * 60 * 60 * 1000), // 6 hours ago
ownerId: 'owner-11',
maxDrivers: 60,
usedDriverSlots: 8,
maxTeams: 20,
usedTeamSlots: 4,
structureSummary: 'Teams • 20 × 3 drivers',
scoringPatternSummary: 'Endurance • All races count',
timingSummary: '45 min Quali • 24h Race',
scoring: {
gameId: 'iracing',
gameName: 'iRacing',
primaryChampionshipType: 'team',
scoringPresetId: 'endurance-team',
scoringPresetName: 'Endurance (Team)',
dropPolicySummary: 'All races count',
scoringPatternSummary: 'Endurance • All races count',
},
},
{
id: 'demo-12',
name: 'iRacing Constructors Battle',
description: 'Team-based championship. Coordinate with your teammate to maximize constructor points.',
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago
ownerId: 'owner-12',
maxDrivers: 20,
usedDriverSlots: 6,
maxTeams: 10,
usedTeamSlots: 3,
structureSummary: 'Teams • 10 × 2 drivers',
scoringPatternSummary: 'Full Season • All rounds count',
timingSummary: '18 min Quali • 60 min Race',
scoring: {
gameId: 'iracing',
gameName: 'iRacing',
primaryChampionshipType: 'team',
scoringPresetId: 'full-season-team',
scoringPresetName: 'Full Season (Team)',
dropPolicySummary: 'All rounds count',
scoringPatternSummary: 'Full Season • All rounds count',
},
},
// Additional popular leagues
{
id: 'demo-13',
name: 'VRS GT Endurance Series',
description: 'Multi-class endurance racing with LMP2 and GT3. Strategic pit stops and driver changes required.',
createdAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000),
ownerId: 'owner-13',
maxDrivers: 54,
usedDriverSlots: 51,
maxTeams: 18,
usedTeamSlots: 17,
structureSummary: 'Teams • 18 × 3 drivers',
scoringPatternSummary: 'Endurance • Best 5 of 6',
timingSummary: '30 min Quali • 4h Race',
scoring: {
gameId: 'iracing',
gameName: 'iRacing',
primaryChampionshipType: 'team',
scoringPresetId: 'endurance-team',
scoringPresetName: 'Endurance (Team)',
dropPolicySummary: 'Best 5 of 6',
scoringPatternSummary: 'Endurance • Best 5 of 6',
},
},
{
id: 'demo-14',
name: 'Ferrari Challenge Series',
description: 'One-make Ferrari 488 Challenge championship. Italian passion meets precision racing.',
createdAt: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000),
ownerId: 'owner-14',
maxDrivers: 24,
usedDriverSlots: 22,
structureSummary: 'Solo • 24 drivers',
scoringPatternSummary: 'Sprint + Main • Best 10 of 12',
timingSummary: '15 min Quali • 35 min Race',
scoring: {
gameId: 'iracing',
gameName: 'iRacing',
primaryChampionshipType: 'trophy',
scoringPresetId: 'sprint-main-trophy',
scoringPresetName: 'Sprint + Main (Trophy)',
dropPolicySummary: 'Best 10 of 12',
scoringPatternSummary: 'Sprint + Main • Best 10 of 12',
},
},
{
id: 'demo-15',
name: 'Oceania Nations Cup',
description: 'Australia and New Zealand battle for Pacific supremacy in this regional nations championship.',
createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000),
ownerId: 'owner-15',
maxDrivers: 20,
usedDriverSlots: 15,
structureSummary: 'Nations • 20 drivers',
scoringPatternSummary: 'Feature Race • Best 6 of 8',
timingSummary: '15 min Quali • 45 min Race',
scoring: {
gameId: 'iracing',
gameName: 'iRacing',
primaryChampionshipType: 'nations',
scoringPresetId: 'feature-nations',
scoringPresetName: 'Feature Race (Nations)',
dropPolicySummary: 'Best 6 of 8',
scoringPatternSummary: 'Feature Race • Best 6 of 8',
},
},
{
id: 'demo-16',
name: 'iRacing Sprint Series',
description: 'Quick 20-minute races for drivers with limited time. Maximum action, minimum commitment.',
createdAt: new Date(Date.now() - 18 * 60 * 60 * 1000), // 18 hours ago
ownerId: 'owner-16',
maxDrivers: 28,
usedDriverSlots: 14,
structureSummary: 'Solo • 28 drivers',
scoringPatternSummary: 'Sprint Only • All races count',
timingSummary: '8 min Quali • 20 min Race',
scoring: {
gameId: 'iracing',
gameName: 'iRacing',
primaryChampionshipType: 'driver',
scoringPresetId: 'sprint-driver',
scoringPresetName: 'Sprint (Driver)',
dropPolicySummary: 'All races count',
scoringPatternSummary: 'Sprint Only • All races count',
},
},
];
// ============================================================================
// 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
// ============================================================================
interface LeagueSliderProps {
title: string;
icon: React.ElementType;
description: string;
leagues: LeagueSummaryDTO[];
onLeagueClick: (id: string) => void;
autoScroll?: boolean;
iconColor?: string;
scrollSpeedMultiplier?: number;
scrollDirection?: 'left' | 'right';
}
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
useEffect(() => {
if (scrollDirection === 'left' && scrollRef.current) {
const { scrollWidth, clientWidth } = scrollRef.current;
scrollPositionRef.current = scrollWidth - clientWidth;
scrollRef.current.scrollLeft = scrollPositionRef.current;
}
}, [scrollDirection, leagues.length]);
// Smooth continuous auto-scroll using requestAnimationFrame with variable speed and direction
useEffect(() => {
// 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
useEffect(() => {
const scrollContainer = scrollRef.current;
if (!scrollContainer) return;
const handleScroll = () => {
scrollPositionRef.current = scrollContainer.scrollLeft;
checkScrollButtons();
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => scrollContainer.removeEventListener('scroll', handleScroll);
}, [checkScrollButtons]);
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 PAGE COMPONENT
// ============================================================================
export default function LeaguesPage() {
const router = useRouter();
const [realLeagues, setRealLeagues] = useState<LeagueSummaryDTO[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [activeCategory, setActiveCategory] = useState<CategoryId>('all');
const [showFilters, setShowFilters] = useState(false);
useEffect(() => {
loadLeagues();
}, []);
const loadLeagues = async () => {
try {
const query = getGetAllLeaguesWithCapacityAndScoringQuery();
const allLeagues = await query.execute();
setRealLeagues(allLeagues);
} catch (error) {
console.error('Failed to load leagues:', error);
} finally {
setLoading(false);
}
};
// Combine real leagues with demo leagues
const leagues = [...realLeagues, ...DEMO_LEAGUES];
const handleLeagueClick = (leagueId: string) => {
// Don't navigate for demo leagues
if (leagueId.startsWith('demo-')) {
return;
}
router.push(`/leagues/${leagueId}`);
};
// 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) => {
acc[category.id] = searchFilteredLeagues.filter(category.filter);
return acc;
},
{} as Record<CategoryId, LeagueSummaryDTO[]>,
);
// 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={() => router.push('/leagues/create')}
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={() => router.push('/leagues/create')}
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={handleLeagueClick}
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={() => handleLeagueClick(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>
);
}