From 5ca2454853d43e937244af8a3f436c868b64a257 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sun, 7 Dec 2025 00:18:02 +0100 Subject: [PATCH] wip --- apps/website/app/leagues/create/page.tsx | 3 +- apps/website/app/leagues/page.tsx | 1099 +++++++++++++++-- apps/website/app/teams/page.tsx | 1087 ++++++++++++---- .../components/leagues/CreateLeagueWizard.tsx | 202 ++- .../leagues/LeagueBasicsSection.tsx | 163 ++- .../website/components/leagues/LeagueCard.tsx | 312 +++-- .../components/leagues/LeagueDropSection.tsx | 35 +- .../leagues/LeagueReviewSummary.tsx | 22 +- .../leagues/LeagueStructureSection.tsx | 617 +++++++-- .../leagues/LeagueVisibilitySection.tsx | 460 +++++++ apps/website/components/teams/TeamCard.tsx | 189 ++- apps/website/lib/leagueWizardService.ts | 66 +- .../application/dto/LeagueConfigFormDTO.ts | 42 +- ...CreateLeagueWithSeasonAndScoringUseCase.ts | 33 +- .../services/ScheduleCalculator.test.ts | 278 +++++ .../domain/services/ScheduleCalculator.ts | 147 +++ .../domain/value-objects/GameConstraints.ts | 176 +++ .../domain/value-objects/LeagueDescription.ts | 89 ++ .../racing/domain/value-objects/LeagueName.ts | 102 ++ .../domain/value-objects/LeagueVisibility.ts | 129 ++ 20 files changed, 4461 insertions(+), 790 deletions(-) create mode 100644 apps/website/components/leagues/LeagueVisibilitySection.tsx create mode 100644 packages/racing/domain/services/ScheduleCalculator.test.ts create mode 100644 packages/racing/domain/services/ScheduleCalculator.ts create mode 100644 packages/racing/domain/value-objects/GameConstraints.ts create mode 100644 packages/racing/domain/value-objects/LeagueDescription.ts create mode 100644 packages/racing/domain/value-objects/LeagueName.ts create mode 100644 packages/racing/domain/value-objects/LeagueVisibility.ts diff --git a/apps/website/app/leagues/create/page.tsx b/apps/website/app/leagues/create/page.tsx index 0c01e3d8a..af458ecf5 100644 --- a/apps/website/app/leagues/create/page.tsx +++ b/apps/website/app/leagues/create/page.tsx @@ -5,11 +5,12 @@ import CreateLeagueWizard from '@/components/leagues/CreateLeagueWizard'; import Section from '@/components/ui/Section'; import Container from '@/components/ui/Container'; -type StepName = 'basics' | 'structure' | 'schedule' | 'scoring' | 'review'; +type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'review'; function normalizeStepName(raw: string | null): StepName { switch (raw) { case 'basics': + case 'visibility': case 'structure': case 'schedule': case 'scoring': diff --git a/apps/website/app/leagues/page.tsx b/apps/website/app/leagues/page.tsx index bd0038173..2472f8e6e 100644 --- a/apps/website/app/leagues/page.tsx +++ b/apps/website/app/leagues/page.tsx @@ -1,20 +1,742 @@ 'use client'; -import { useState, useEffect } from 'react'; +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(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(true); + const [isHovering, setIsHovering] = useState(false); + const animationRef = useRef(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 ( +
+ {/* Section header */} +
+
+
+ +
+
+

{title}

+

{description}

+
+ + {leagues.length} + +
+ + {/* Navigation arrows */} +
+ + +
+
+ + {/* Scrollable container with fade edges */} +
+ {/* Left fade gradient */} +
+ {/* Right fade gradient */} +
+ +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + className="flex gap-4 overflow-x-auto pb-4 px-4" + style={{ + scrollbarWidth: 'none', + msOverflowStyle: 'none', + }} + > + + {leagues.map((league) => ( +
+ onLeagueClick(league.id)} /> +
+ ))} +
+
+
+ ); +} + +// ============================================================================ +// MAIN PAGE COMPONENT +// ============================================================================ + export default function LeaguesPage() { const router = useRouter(); - const [leagues, setLeagues] = useState([]); + const [realLeagues, setRealLeagues] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); - const [sortBy, setSortBy] = useState('name'); + const [activeCategory, setActiveCategory] = useState('all'); + const [showFilters, setShowFilters] = useState(false); useEffect(() => { loadLeagues(); @@ -24,7 +746,7 @@ export default function LeaguesPage() { try { const query = getGetAllLeaguesWithCapacityAndScoringQuery(); const allLeagues = await query.execute(); - setLeagues(allLeagues); + setRealLeagues(allLeagues); } catch (error) { console.error('Failed to load leagues:', error); } finally { @@ -32,140 +754,279 @@ export default function LeaguesPage() { } }; + // 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}`); }; - const filteredLeagues = leagues - .filter((league) => { - const matchesSearch = - league.name.toLowerCase().includes(searchQuery.toLowerCase()) || - (league.description ?? '').toLowerCase().includes(searchQuery.toLowerCase()); - return matchesSearch; - }) - .sort((a, b) => { - switch (sortBy) { - case 'name': - return a.name.localeCompare(b.name); - case 'recent': - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - default: - return 0; - } - }); + // 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, + ); + + // 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 ( -
-
Loading leagues...
+
+
+
+
+

Loading leagues...

+
+
); } return ( -
-
-
-

Leagues

-

- {leagues.length === 0 - ? 'Create your first league to get started' - : `${leagues.length} ${leagues.length === 1 ? 'league' : 'leagues'} available`} +

+ {/* Hero Section */} +
+ {/* Background decoration */} +
+
+ +
+
+
+
+ +
+ + Find Your Grid + +
+

+ From casual sprints to epic endurance battles — discover the perfect league for your racing style.

+ + {/* Stats */} +
+
+
+ + {leagues.length} active leagues + +
+
+
+ + {leaguesByCategory.new.length} new this week + +
+
+
+ + {leaguesByCategory.openSlots.length} with open slots + +
+
- + + {/* CTA */} +
+ +

Set up your own racing series

+
+
+
+ + {/* Search and Filter Bar */} +
+
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="pl-11" + /> +
+ + {/* Filter toggle (mobile) */}
- {leagues.length > 0 && ( - -
-
- - setSearchQuery(e.target.value)} - /> -
+ {/* Category Tabs */} +
+
+ {CATEGORIES.map((category) => { + const Icon = category.icon; + const count = leaguesByCategory[category.id].length; + const isActive = activeCategory === category.id; -
- - -
-
- - )} - - {leagues.length === 0 ? ( - -
- -

No leagues yet

-

- Create one to get started. Alpha data resets on page reload. -

- -
-
- ) : ( - <> -
-

- {filteredLeagues.length} {filteredLeagues.length === 1 ? 'league' : 'leagues'} found -

-
-
- {filteredLeagues.map((league) => ( - handleLeagueClick(league.id)} - /> - ))} -
- {filteredLeagues.length === 0 && ( -
-

No leagues found matching your search.

-
- )} - - )} + + {category.label} + {count > 0 && ( + + {count} + + )} + + ); + })} +
+
- ); - } \ No newline at end of file + + {/* Content */} + {leagues.length === 0 ? ( + /* Empty State */ + +
+
+ +
+ + No leagues yet + +

+ Be the first to create a racing series. Start your own league and invite drivers to compete for glory. +

+ +
+
+ ) : activeCategory === 'all' && !searchQuery ? ( + /* Slider View - Show featured categories with sliders at different speeds and directions */ +
+ {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 }) => ( + + ))} +
+ ) : ( + /* Grid View - Filtered by category or search */ +
+ {categoryFilteredLeagues.length > 0 ? ( + <> +
+

+ Showing {categoryFilteredLeagues.length}{' '} + {categoryFilteredLeagues.length === 1 ? 'league' : 'leagues'} + {searchQuery && ( + + {' '} + for "{searchQuery}" + + )} +

+
+
+ {categoryFilteredLeagues.map((league) => ( + handleLeagueClick(league.id)} /> + ))} +
+ + ) : ( + +
+ +

+ No leagues found{searchQuery ? ` matching "${searchQuery}"` : ' in this category'} +

+ +
+
+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/website/app/teams/page.tsx b/apps/website/app/teams/page.tsx index 5c62808b9..e82febd9d 100644 --- a/apps/website/app/teams/page.tsx +++ b/apps/website/app/teams/page.tsx @@ -1,137 +1,695 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useRouter } from 'next/navigation'; +import { + Users, + Trophy, + Search, + Plus, + ChevronLeft, + ChevronRight, + Sparkles, + Filter, + Flame, + Target, + Star, + TrendingUp, + Shield, + Zap, + Award, + Crown, + UserPlus, +} from 'lucide-react'; +import TeamCard from '@/components/teams/TeamCard'; import Button from '@/components/ui/Button'; -import Input from '@/components/ui/Input'; 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 TeamLadderRow from '@/components/teams/TeamLadderRow'; import { getGetAllTeamsQuery, getGetTeamMembersQuery, getDriverStats } from '@/lib/di-container'; import type { Team } from '@gridpilot/racing'; -type TeamLadderItem = { - team: Team; +// ============================================================================ +// TYPES +// ============================================================================ + +type CategoryId = + | 'all' + | 'recruiting' + | 'popular' + | 'new' + | 'pro' + | 'advanced' + | 'intermediate' + | 'beginner' + | 'endurance' + | 'sprint'; + +interface Category { + id: CategoryId; + label: string; + icon: React.ElementType; + description: string; + filter: (team: TeamDisplayData) => boolean; + color?: string; +} + +interface TeamDisplayData { + id: string; + name: string; memberCount: number; rating: number | null; totalWins: number; totalRaces: number; -}; + performanceLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro'; + isRecruiting: boolean; + createdAt: Date; + description?: string; + specialization?: 'endurance' | 'sprint' | 'mixed'; +} + +// ============================================================================ +// DEMO TEAMS DATA +// ============================================================================ + +const DEMO_TEAMS: TeamDisplayData[] = [ + // Pro Teams + { + id: 'demo-team-1', + name: 'Apex Predators Racing', + description: 'Elite GT3 team competing at the highest level. Multiple championship winners seeking consistent drivers.', + memberCount: 8, + rating: 4850, + totalWins: 47, + totalRaces: 156, + performanceLevel: 'pro', + isRecruiting: true, + createdAt: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000), + specialization: 'mixed', + }, + { + id: 'demo-team-2', + name: 'Velocity Esports', + description: 'Professional sim racing team with sponsors. Competing in major endurance events worldwide.', + memberCount: 12, + rating: 5200, + totalWins: 63, + totalRaces: 198, + performanceLevel: 'pro', + isRecruiting: false, + createdAt: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000), + specialization: 'endurance', + }, + { + id: 'demo-team-3', + name: 'Nitro Motorsport', + description: 'Championship-winning sprint specialists. Fast, consistent, and always fighting for podiums.', + memberCount: 6, + rating: 4720, + totalWins: 38, + totalRaces: 112, + performanceLevel: 'pro', + isRecruiting: true, + createdAt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000), + specialization: 'sprint', + }, + // Advanced Teams + { + id: 'demo-team-4', + name: 'Horizon Racing Collective', + description: 'Ambitious team on the rise. Building towards professional competition with dedicated drivers.', + memberCount: 10, + rating: 3800, + totalWins: 24, + totalRaces: 89, + performanceLevel: 'advanced', + isRecruiting: true, + createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), + specialization: 'mixed', + }, + { + id: 'demo-team-5', + name: 'Phoenix Rising eSports', + description: 'From the ashes to the podium. A team built on improvement and teamwork.', + memberCount: 7, + rating: 3650, + totalWins: 19, + totalRaces: 76, + performanceLevel: 'advanced', + isRecruiting: true, + createdAt: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000), + specialization: 'endurance', + }, + { + id: 'demo-team-6', + name: 'Thunderbolt Racing', + description: 'Fast and furious sprint racing. We live for wheel-to-wheel battles.', + memberCount: 5, + rating: 3420, + totalWins: 15, + totalRaces: 54, + performanceLevel: 'advanced', + isRecruiting: false, + createdAt: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000), + specialization: 'sprint', + }, + // Intermediate Teams + { + id: 'demo-team-7', + name: 'Grid Starters', + description: 'Growing together as racers. Friendly competition with a focus on learning and fun.', + memberCount: 9, + rating: 2800, + totalWins: 11, + totalRaces: 67, + performanceLevel: 'intermediate', + isRecruiting: true, + createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + specialization: 'mixed', + }, + { + id: 'demo-team-8', + name: 'Midnight Racers', + description: 'Night owls who love endurance racing. Join us for late-night stints and good vibes.', + memberCount: 6, + rating: 2650, + totalWins: 8, + totalRaces: 42, + performanceLevel: 'intermediate', + isRecruiting: true, + createdAt: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000), + specialization: 'endurance', + }, + { + id: 'demo-team-9', + name: 'Casual Speedsters', + description: 'Racing for fun, improving together. No pressure, just clean racing.', + memberCount: 4, + rating: 2400, + totalWins: 5, + totalRaces: 31, + performanceLevel: 'intermediate', + isRecruiting: true, + createdAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + specialization: 'sprint', + }, + // Beginner Teams + { + id: 'demo-team-10', + name: 'Fresh Rubber Racing', + description: 'New team for new racers! Learn the basics together in a supportive environment.', + memberCount: 3, + rating: 1800, + totalWins: 2, + totalRaces: 18, + performanceLevel: 'beginner', + isRecruiting: true, + createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), + specialization: 'mixed', + }, + { + id: 'demo-team-11', + name: 'Rookie Revolution', + description: 'First time racers welcome! We all start somewhere.', + memberCount: 5, + rating: 1650, + totalWins: 1, + totalRaces: 12, + performanceLevel: 'beginner', + isRecruiting: true, + createdAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000), + specialization: 'sprint', + }, + { + id: 'demo-team-12', + name: 'Pit Lane Pioneers', + description: 'Learning endurance racing from scratch. Long races, longer friendships.', + memberCount: 4, + rating: 1500, + totalWins: 0, + totalRaces: 8, + performanceLevel: 'beginner', + isRecruiting: true, + createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), + specialization: 'endurance', + }, + // Recently Added + { + id: 'demo-team-13', + name: 'Shadow Squadron', + description: 'Elite drivers emerging from the shadows. Watch out for us this season.', + memberCount: 6, + rating: 4100, + totalWins: 12, + totalRaces: 34, + performanceLevel: 'advanced', + isRecruiting: true, + createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), + specialization: 'mixed', + }, + { + id: 'demo-team-14', + name: 'Turbo Collective', + description: 'Fast, furious, and friendly. Sprint racing specialists looking for quick racers.', + memberCount: 4, + rating: 3200, + totalWins: 7, + totalRaces: 28, + performanceLevel: 'intermediate', + isRecruiting: true, + createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000), + specialization: 'sprint', + }, +]; + +// ============================================================================ +// CATEGORIES +// ============================================================================ + +const CATEGORIES: Category[] = [ + { + id: 'all', + label: 'All', + icon: Users, + description: 'Browse all teams', + filter: () => true, + }, + { + id: 'recruiting', + label: 'Recruiting', + icon: UserPlus, + description: 'Teams looking for drivers', + filter: (team) => team.isRecruiting, + color: 'text-performance-green', + }, + { + id: 'popular', + label: 'Popular', + icon: Flame, + description: 'Most active teams', + filter: (team) => team.totalRaces >= 50, + color: 'text-orange-400', + }, + { + id: 'new', + label: 'New', + icon: Sparkles, + description: 'Recently formed teams', + filter: (team) => { + const oneWeekAgo = new Date(); + oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); + return team.createdAt > oneWeekAgo; + }, + color: 'text-neon-aqua', + }, + { + id: 'pro', + label: 'Pro', + icon: Crown, + description: 'Professional-level teams', + filter: (team) => team.performanceLevel === 'pro', + color: 'text-yellow-400', + }, + { + id: 'advanced', + label: 'Advanced', + icon: Star, + description: 'High-skill teams', + filter: (team) => team.performanceLevel === 'advanced', + color: 'text-purple-400', + }, + { + id: 'intermediate', + label: 'Intermediate', + icon: TrendingUp, + description: 'Growing teams', + filter: (team) => team.performanceLevel === 'intermediate', + color: 'text-primary-blue', + }, + { + id: 'beginner', + label: 'Beginner', + icon: Shield, + description: 'New racer friendly', + filter: (team) => team.performanceLevel === 'beginner', + color: 'text-green-400', + }, + { + id: 'endurance', + label: 'Endurance', + icon: Trophy, + description: 'Long-distance specialists', + filter: (team) => team.specialization === 'endurance', + }, + { + id: 'sprint', + label: 'Sprint', + icon: Zap, + description: 'Short race experts', + filter: (team) => team.specialization === 'sprint', + }, +]; + +// ============================================================================ +// TEAM SLIDER COMPONENT +// ============================================================================ + +interface TeamSliderProps { + title: string; + icon: React.ElementType; + description: string; + teams: TeamDisplayData[]; + onTeamClick: (id: string) => void; + autoScroll?: boolean; + iconColor?: string; + scrollSpeedMultiplier?: number; + scrollDirection?: 'left' | 'right'; +} + +function TeamSlider({ + title, + icon: Icon, + description, + teams, + onTeamClick, + autoScroll = true, + iconColor = 'text-primary-blue', + scrollSpeedMultiplier = 1, + scrollDirection = 'right', +}: TeamSliderProps) { + const scrollRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(true); + const [isHovering, setIsHovering] = useState(false); + const animationRef = useRef(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; + 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, teams.length]); + + // Smooth continuous auto-scroll + useEffect(() => { + if (!autoScroll || teams.length <= 1) return; + + const scrollContainer = scrollRef.current; + if (!scrollContainer) return; + + let lastTimestamp = 0; + 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; + + 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, teams.length, isHovering, scrollSpeedMultiplier, scrollDirection]); + + 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 (teams.length === 0) return null; + + return ( +
+
+
+
+ +
+
+

{title}

+

{description}

+
+ + {teams.length} + +
+ +
+ + +
+
+ +
+
+
+ +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + className="flex gap-4 overflow-x-auto pb-4 px-4" + style={{ + scrollbarWidth: 'none', + msOverflowStyle: 'none', + }} + > + + {teams.map((team) => ( +
+ onTeamClick(team.id)} + /> +
+ ))} +
+
+
+ ); +} + +// ============================================================================ +// MAIN PAGE COMPONENT +// ============================================================================ export default function TeamsPage() { const router = useRouter(); - const [teams, setTeams] = useState([]); - const [showCreateForm, setShowCreateForm] = useState(false); + const [realTeams, setRealTeams] = useState([]); + const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); - const [memberFilter, setMemberFilter] = useState('all'); - const [memberCounts, setMemberCounts] = useState>({}); - const [teamStats, setTeamStats] = useState>>({}); - - const loadTeams = async () => { - const allTeamsQuery = getGetAllTeamsQuery(); - const teamMembersQuery = getGetTeamMembersQuery(); - - const allTeams = await allTeamsQuery.execute(); - setTeams(allTeams); - - const counts: Record = {}; - const stats: Record> = {}; - - await Promise.all( - allTeams.map(async (team) => { - const memberships = await teamMembersQuery.execute({ teamId: team.id }); - counts[team.id] = memberships.length; - - const memberDriverIds = memberships.map((m) => m.driverId); - - let ratingSum = 0; - let ratingCount = 0; - let totalWins = 0; - let totalRaces = 0; - - for (const driverId of memberDriverIds) { - const statsForDriver = getDriverStats(driverId); - if (!statsForDriver) continue; - - if (typeof statsForDriver.rating === 'number') { - ratingSum += statsForDriver.rating; - ratingCount += 1; - } - - totalWins += statsForDriver.wins ?? 0; - totalRaces += statsForDriver.totalRaces ?? 0; - } - - const averageRating = - ratingCount > 0 ? ratingSum / ratingCount : null; - - stats[team.id] = { - rating: averageRating, - totalWins, - totalRaces, - }; - }), - ); - - setMemberCounts(counts); - setTeamStats(stats); - }; + const [activeCategory, setActiveCategory] = useState('all'); + const [showFilters, setShowFilters] = useState(false); + const [showCreateForm, setShowCreateForm] = useState(false); useEffect(() => { - void loadTeams(); + loadTeams(); }, []); + const loadTeams = async () => { + try { + const allTeamsQuery = getGetAllTeamsQuery(); + const teamMembersQuery = getGetTeamMembersQuery(); + + const allTeams = await allTeamsQuery.execute(); + const teamData: TeamDisplayData[] = []; + + await Promise.all( + allTeams.map(async (team: Team) => { + const memberships = await teamMembersQuery.execute({ teamId: team.id }); + const memberCount = memberships.length; + + let ratingSum = 0; + let ratingCount = 0; + let totalWins = 0; + let totalRaces = 0; + + for (const membership of memberships) { + const stats = getDriverStats(membership.driverId); + if (!stats) continue; + + if (typeof stats.rating === 'number') { + ratingSum += stats.rating; + ratingCount += 1; + } + + totalWins += stats.wins ?? 0; + totalRaces += stats.totalRaces ?? 0; + } + + const averageRating = ratingCount > 0 ? ratingSum / ratingCount : null; + + let performanceLevel: TeamDisplayData['performanceLevel'] = 'beginner'; + if (averageRating !== null) { + if (averageRating >= 4500) performanceLevel = 'pro'; + else if (averageRating >= 3000) performanceLevel = 'advanced'; + else if (averageRating >= 2000) performanceLevel = 'intermediate'; + } + + teamData.push({ + id: team.id, + name: team.name, + memberCount, + rating: averageRating, + totalWins, + totalRaces, + performanceLevel, + isRecruiting: true, // Default for now + createdAt: new Date(), // Would need to be stored in Team entity + }); + }), + ); + + setRealTeams(teamData); + } catch (error) { + console.error('Failed to load teams:', error); + } finally { + setLoading(false); + } + }; + + // Combine real teams with demo teams + const teams = [...realTeams, ...DEMO_TEAMS]; + + const handleTeamClick = (teamId: string) => { + if (teamId.startsWith('demo-team-')) { + return; + } + router.push(`/teams/${teamId}`); + }; + const handleCreateSuccess = (teamId: string) => { setShowCreateForm(false); void loadTeams(); router.push(`/teams/${teamId}`); }; - const filteredTeams = teams.filter((team) => { - const memberCount = memberCounts[team.id] ?? 0; - - const matchesSearch = team.name.toLowerCase().includes(searchQuery.toLowerCase()); - const matchesMemberCount = - memberFilter === 'all' || - (memberFilter === 'small' && memberCount < 5) || - (memberFilter === 'medium' && memberCount >= 5 && memberCount < 10) || - (memberFilter === 'large' && memberCount >= 10); - - return matchesSearch && matchesMemberCount; + // Filter by search query + const searchFilteredTeams = teams.filter((team) => { + if (!searchQuery) return true; + const query = searchQuery.toLowerCase(); + return ( + team.name.toLowerCase().includes(query) || + (team.description ?? '').toLowerCase().includes(query) + ); }); - const sortedTeams = [...filteredTeams].sort((a, b) => { - const statsA = teamStats[a.id]; - const statsB = teamStats[b.id]; + // Get teams for active category + const activeCategoryData = CATEGORIES.find((c) => c.id === activeCategory); + const categoryFilteredTeams = activeCategoryData + ? searchFilteredTeams.filter(activeCategoryData.filter) + : searchFilteredTeams; - const ratingA = statsA?.rating ?? null; - const ratingB = statsB?.rating ?? null; + // Group teams by category for slider view + const teamsByCategory = CATEGORIES.reduce( + (acc, category) => { + acc[category.id] = searchFilteredTeams.filter(category.filter); + return acc; + }, + {} as Record, + ); - const ratingValA = ratingA === null ? Number.NEGATIVE_INFINITY : ratingA; - const ratingValB = ratingB === null ? Number.NEGATIVE_INFINITY : ratingB; - - if (ratingValA !== ratingValB) { - return ratingValB - ratingValA; - } - - const winsA = statsA?.totalWins ?? 0; - const winsB = statsB?.totalWins ?? 0; - - if (winsA !== winsB) { - return winsB - winsA; - } - - return a.name.localeCompare(b.name); - }); - - const handleTeamClick = (teamId: string) => { - router.push(`/teams/${teamId}`); - }; + // Featured categories with different scroll speeds and directions + const featuredCategoriesWithSpeed: { id: CategoryId; speed: number; direction: 'left' | 'right' }[] = [ + { id: 'recruiting', speed: 1.0, direction: 'right' }, + { id: 'pro', speed: 0.8, direction: 'left' }, + { id: 'advanced', speed: 1.1, direction: 'right' }, + { id: 'intermediate', speed: 0.9, direction: 'left' }, + { id: 'beginner', speed: 1.2, direction: 'right' }, + { id: 'new', speed: 1.0, direction: 'left' }, + ]; if (showCreateForm) { return ( -
- +
+

Start your own racing team

+
-
- -
-
- + {/* Search and Filter Bar */} +
+
+
+ setSearchQuery(e.target.value)} + className="pl-11" />
-
- - + +
+ + {/* Category Tabs */} +
+
+ {CATEGORIES.map((category) => { + const Icon = category.icon; + const count = teamsByCategory[category.id].length; + const isActive = activeCategory === category.id; + + return ( + + ); + })}
- - -
-

- {sortedTeams.length} {sortedTeams.length === 1 ? 'team' : 'teams'} found -

- {sortedTeams.length > 0 && ( - -
- - - - - - - - - - - - - {sortedTeams.map((team, index) => { - const memberCount = memberCounts[team.id] ?? 0; - const statsForTeam = teamStats[team.id]; - - const rating = statsForTeam?.rating ?? null; - const totalWins = statsForTeam?.totalWins ?? 0; - const totalRaces = statsForTeam?.totalRaces ?? 0; - const rank = index + 1; - - return ( - - ); - })} - -
#TeamRatingWinsRacesMembers
-
-
- )} - - {filteredTeams.length === 0 && ( - -
- -

- {teams.length === 0 ? 'No teams yet' : 'No teams found'} -

-

- {teams.length === 0 - ? 'Create your first team to start racing together.' - : 'Try adjusting your search or filters.'} + {/* Content */} + {teams.length === 0 ? ( + +

+
+ +
+ + No teams yet + +

+ Be the first to create a racing team. Gather drivers and compete together in endurance events.

- {teams.length === 0 && ( - - )} +
+ ) : activeCategory === 'all' && !searchQuery ? ( + /* Slider View */ +
+ {featuredCategoriesWithSpeed + .map(({ id, speed, direction }) => { + const category = CATEGORIES.find((c) => c.id === id)!; + return { category, speed, direction }; + }) + .filter(({ category }) => teamsByCategory[category.id].length > 0) + .map(({ category, speed, direction }) => ( + + ))} +
+ ) : ( + /* Grid View */ +
+ {categoryFilteredTeams.length > 0 ? ( + <> +
+

+ Showing {categoryFilteredTeams.length}{' '} + {categoryFilteredTeams.length === 1 ? 'team' : 'teams'} + {searchQuery && ( + + {' '} + for "{searchQuery}" + + )} +

+
+
+ {categoryFilteredTeams.map((team) => ( + handleTeamClick(team.id)} + /> + ))} +
+ + ) : ( + +
+ +

+ No teams found{searchQuery ? ` matching "${searchQuery}"` : ' in this category'} +

+ +
+
+ )} +
)}
); diff --git a/apps/website/components/leagues/CreateLeagueWizard.tsx b/apps/website/components/leagues/CreateLeagueWizard.tsx index 0ba02cf44..bf7ca406d 100644 --- a/apps/website/components/leagues/CreateLeagueWizard.tsx +++ b/apps/website/components/leagues/CreateLeagueWizard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState, FormEvent } from 'react'; +import { useEffect, useState, FormEvent, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { FileText, @@ -33,6 +33,7 @@ import { import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider'; import type { LeagueConfigFormModel } from '@gridpilot/racing/application'; import { LeagueBasicsSection } from './LeagueBasicsSection'; +import { LeagueVisibilitySection } from './LeagueVisibilitySection'; import { LeagueStructureSection } from './LeagueStructureSection'; import { LeagueScoringSection, @@ -42,9 +43,65 @@ import { import { LeagueDropSection } from './LeagueDropSection'; import { LeagueTimingsSection } from './LeagueTimingsSection'; -type Step = 1 | 2 | 3 | 4 | 5; +// ============================================================================ +// LOCAL STORAGE PERSISTENCE +// ============================================================================ -type StepName = 'basics' | 'structure' | 'schedule' | 'scoring' | 'review'; +const STORAGE_KEY = 'gridpilot_league_wizard_draft'; +const STORAGE_HIGHEST_STEP_KEY = 'gridpilot_league_wizard_highest_step'; + +function saveFormToStorage(form: LeagueConfigFormModel): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(form)); + } catch { + // Ignore storage errors (quota exceeded, etc.) + } +} + +function loadFormFromStorage(): LeagueConfigFormModel | null { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + return JSON.parse(stored) as LeagueConfigFormModel; + } + } catch { + // Ignore parse errors + } + return null; +} + +function clearFormStorage(): void { + try { + localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_HIGHEST_STEP_KEY); + } catch { + // Ignore storage errors + } +} + +function saveHighestStep(step: number): void { + try { + const current = getHighestStep(); + if (step > current) { + localStorage.setItem(STORAGE_HIGHEST_STEP_KEY, String(step)); + } + } catch { + // Ignore storage errors + } +} + +function getHighestStep(): number { + try { + const stored = localStorage.getItem(STORAGE_HIGHEST_STEP_KEY); + return stored ? parseInt(stored, 10) : 1; + } catch { + return 1; + } +} + +type Step = 1 | 2 | 3 | 4 | 5 | 6; + +type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'review'; interface CreateLeagueWizardProps { stepName: StepName; @@ -55,14 +112,16 @@ function stepNameToStep(stepName: StepName): Step { switch (stepName) { case 'basics': return 1; - case 'structure': + case 'visibility': return 2; - case 'schedule': + case 'structure': return 3; - case 'scoring': + case 'schedule': return 4; - case 'review': + case 'scoring': return 5; + case 'review': + return 6; } } @@ -71,18 +130,29 @@ function stepToStepName(step: Step): StepName { case 1: return 'basics'; case 2: - return 'structure'; + return 'visibility'; case 3: - return 'schedule'; + return 'structure'; case 4: - return 'scoring'; + return 'schedule'; case 5: + return 'scoring'; + case 6: return 'review'; } } import type { WizardErrors } from '@/lib/leagueWizardService'; +function getDefaultSeasonStartDate(): string { + // Default to next Saturday + const now = new Date(); + const daysUntilSaturday = (6 - now.getDay() + 7) % 7 || 7; + const nextSaturday = new Date(now); + nextSaturday.setDate(now.getDate() + daysUntilSaturday); + return nextSaturday.toISOString().split('T')[0]; +} + function createDefaultForm(): LeagueConfigFormModel { const defaultPatternId = 'sprint-main-driver'; @@ -121,6 +191,12 @@ function createDefaultForm(): LeagueConfigFormModel { mainRaceMinutes: 40, sessionCount: 2, roundsPlanned: 8, + // Default to Saturday races, weekly, starting next week + weekdays: ['Sat'] as import('@gridpilot/racing/domain/value-objects/Weekday').Weekday[], + recurrenceStrategy: 'weekly' as const, + raceStartTime: '20:00', + timezoneId: 'UTC', + seasonStartDate: getDefaultSeasonStartDate(), }, }; } @@ -133,10 +209,39 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea const [presetsLoading, setPresetsLoading] = useState(true); const [presets, setPresets] = useState([]); const [errors, setErrors] = useState({}); + const [highestCompletedStep, setHighestCompletedStep] = useState(1); + const [isHydrated, setIsHydrated] = useState(false); + + // Initialize form from localStorage or defaults const [form, setForm] = useState(() => createDefaultForm(), ); + // Hydrate from localStorage on mount + useEffect(() => { + const stored = loadFormFromStorage(); + if (stored) { + setForm(stored); + } + setHighestCompletedStep(getHighestStep()); + setIsHydrated(true); + }, []); + + // Save form to localStorage whenever it changes (after hydration) + useEffect(() => { + if (isHydrated) { + saveFormToStorage(form); + } + }, [form, isHydrated]); + + // Track highest step reached + useEffect(() => { + if (isHydrated) { + saveHighestStep(step); + setHighestCompletedStep((prev) => Math.max(prev, step)); + } + }, [step, isHydrated]); + useEffect(() => { async function loadPresets() { try { @@ -182,7 +287,9 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea if (!validateStep(step)) { return; } - const nextStep = (step < 5 ? ((step + 1) as Step) : step); + const nextStep = (step < 6 ? ((step + 1) as Step) : step); + saveHighestStep(nextStep); + setHighestCompletedStep((prev) => Math.max(prev, nextStep)); onStepChange(stepToStepName(nextStep)); }; @@ -191,6 +298,13 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea onStepChange(stepToStepName(prevStep)); }; + // Navigate to a specific step (only if it's been reached before) + const goToStep = useCallback((targetStep: Step) => { + if (targetStep <= highestCompletedStep) { + onStepChange(stepToStepName(targetStep)); + } + }, [highestCompletedStep, onStepChange]); + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); if (loading) return; @@ -211,6 +325,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea try { const result = await createLeagueFromConfig(form); + // Clear the draft on successful creation + clearFormStorage(); router.push(`/leagues/${result.leagueId}`); } catch (err) { const message = @@ -233,10 +349,11 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea const steps = [ { id: 1 as Step, label: 'Basics', icon: FileText, shortLabel: 'Name' }, - { id: 2 as Step, label: 'Structure', icon: Users, shortLabel: 'Mode' }, - { id: 3 as Step, label: 'Schedule', icon: Calendar, shortLabel: 'Time' }, - { id: 4 as Step, label: 'Scoring', icon: Trophy, shortLabel: 'Points' }, - { id: 5 as Step, label: 'Review', icon: CheckCircle2, shortLabel: 'Done' }, + { id: 2 as Step, label: 'Visibility', icon: Award, shortLabel: 'Type' }, + { id: 3 as Step, label: 'Structure', icon: Users, shortLabel: 'Mode' }, + { id: 4 as Step, label: 'Schedule', icon: Calendar, shortLabel: 'Time' }, + { id: 5 as Step, label: 'Scoring', icon: Trophy, shortLabel: 'Points' }, + { id: 6 as Step, label: 'Review', icon: CheckCircle2, shortLabel: 'Done' }, ]; const getStepTitle = (currentStep: Step): string => { @@ -244,12 +361,14 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea case 1: return 'Name your league'; case 2: - return 'Choose the structure'; + return 'Choose your destiny'; case 3: - return 'Set the schedule'; + return 'Choose the structure'; case 4: - return 'Scoring & championships'; + return 'Set the schedule'; case 5: + return 'Scoring & championships'; + case 6: return 'Review & create'; default: return ''; @@ -259,14 +378,16 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea const getStepSubtitle = (currentStep: Step): string => { switch (currentStep) { case 1: - return 'Give your league a memorable name and choose who can join.'; + return 'Give your league a memorable name and tell your story.'; case 2: - return 'Will drivers compete individually or as part of teams?'; + return 'Will you compete for global rankings or race with friends?'; case 3: - return 'Configure session durations and plan your season calendar.'; + return 'Will drivers compete individually or as part of teams?'; case 4: - return 'Select a scoring preset, enable championships, and set drop rules.'; + return 'Configure session durations and plan your season calendar.'; case 5: + return 'Select a scoring preset, enable championships, and set drop rules.'; + case 6: return 'Everything looks good? Launch your new league!'; default: return ''; @@ -310,10 +431,17 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea {steps.map((wizardStep) => { const isCompleted = wizardStep.id < step; const isCurrent = wizardStep.id === step; + const isAccessible = wizardStep.id <= highestCompletedStep; const StepIcon = wizardStep.icon; return ( -
+
+ ); })}
@@ -429,6 +561,16 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea )} {step === 2 && ( +
+ +
+ )} + + {step === 3 && (
)} - {step === 3 && ( + {step === 4 && (
)} - {step === 4 && ( + {step === 5 && (
{/* Scoring Pattern Selection */} )} - {step === 5 && ( + {step === 6 && (
{errors.submit && ( @@ -527,7 +669,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea ))}
- {step < 5 ? ( + {step < 6 ? ( - +
- {/* Description */} -
+ {/* Description - Now Required */} +