This commit is contained in:
2025-12-07 00:18:02 +01:00
parent 70d5f5689e
commit 5ca2454853
20 changed files with 4461 additions and 790 deletions

View File

@@ -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':

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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<LeagueScoringPresetDTO[]>([]);
const [errors, setErrors] = useState<WizardErrors>({});
const [highestCompletedStep, setHighestCompletedStep] = useState(1);
const [isHydrated, setIsHydrated] = useState(false);
// Initialize form from localStorage or defaults
const [form, setForm] = useState<LeagueConfigFormModel>(() =>
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 (
<div key={wizardStep.id} className="flex flex-col items-center">
<button
key={wizardStep.id}
type="button"
onClick={() => goToStep(wizardStep.id)}
disabled={!isAccessible}
className="flex flex-col items-center bg-transparent border-0 cursor-pointer disabled:cursor-not-allowed"
>
<div
className={`
relative z-10 flex h-10 w-10 items-center justify-center rounded-full
@@ -321,8 +449,10 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
${isCurrent
? 'bg-primary-blue text-white shadow-[0_0_24px_rgba(25,140,255,0.5)] scale-110'
: isCompleted
? 'bg-primary-blue text-white'
: 'bg-iron-gray text-gray-500 border-2 border-charcoal-outline'
? 'bg-primary-blue text-white hover:scale-105'
: isAccessible
? 'bg-iron-gray text-gray-400 border-2 border-charcoal-outline hover:border-primary-blue/50'
: 'bg-iron-gray text-gray-500 border-2 border-charcoal-outline opacity-60'
}
`}
>
@@ -339,13 +469,15 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
? 'text-white'
: isCompleted
? 'text-primary-blue'
: isAccessible
? 'text-gray-400'
: 'text-gray-500'
}`}
>
{wizardStep.label}
</p>
</div>
</div>
</button>
);
})}
</div>
@@ -429,6 +561,16 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
)}
{step === 2 && (
<div className="animate-fade-in">
<LeagueVisibilitySection
form={form}
onChange={setForm}
errors={errors.basics}
/>
</div>
)}
{step === 3 && (
<div className="animate-fade-in">
<LeagueStructureSection
form={form}
@@ -438,7 +580,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
</div>
)}
{step === 3 && (
{step === 4 && (
<div className="animate-fade-in">
<LeagueTimingsSection
form={form}
@@ -448,7 +590,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
</div>
)}
{step === 4 && (
{step === 5 && (
<div className="animate-fade-in space-y-8">
{/* Scoring Pattern Selection */}
<ScoringPatternSection
@@ -486,7 +628,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
</div>
)}
{step === 5 && (
{step === 6 && (
<div className="animate-fade-in space-y-6">
<LeagueReviewSummary form={form} presets={presets} />
{errors.submit && (
@@ -527,7 +669,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
))}
</div>
{step < 5 ? (
{step < 6 ? (
<Button
type="button"
variant="primary"

View File

@@ -1,6 +1,6 @@
'use client';
import { FileText, Globe, Lock, Gamepad2 } from 'lucide-react';
import { FileText, Gamepad2, AlertCircle, Check } from 'lucide-react';
import Input from '@/components/ui/Input';
import type {
LeagueConfigFormModel,
@@ -11,7 +11,7 @@ interface LeagueBasicsSectionProps {
onChange?: (form: LeagueConfigFormModel) => void;
errors?: {
name?: string;
visibility?: string;
description?: string;
};
readOnly?: boolean;
}
@@ -37,9 +37,19 @@ export function LeagueBasicsSection({
};
return (
<div className="space-y-6">
<div className="space-y-8">
{/* Emotional header for the step */}
<div className="text-center pb-2">
<h3 className="text-lg font-semibold text-white mb-2">
Every great championship starts with a name
</h3>
<p className="text-sm text-gray-400 max-w-lg mx-auto">
This is where legends begin. Give your league an identity that drivers will remember.
</p>
</div>
{/* League name */}
<div className="space-y-2">
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
<FileText className="w-4 h-4 text-primary-blue" />
League name *
@@ -53,37 +63,45 @@ export function LeagueBasicsSection({
disabled={disabled}
autoFocus
/>
<div className="space-y-1">
<div className="space-y-2">
<p className="text-xs text-gray-500">
Choose a clear, memorable name that describes your league
Make it memorable this is what drivers will see first
</p>
<div className="flex flex-wrap gap-2">
<span className="text-xs text-gray-500">Try:</span>
<button
type="button"
onClick={() => updateBasics({ name: 'Weekly Sprint Championship' })}
className="text-xs text-primary-blue hover:text-primary-blue/80 transition-colors"
onClick={() => updateBasics({ name: 'Sunday Showdown Series' })}
className="text-xs px-2 py-0.5 rounded-full bg-primary-blue/10 text-primary-blue hover:bg-primary-blue/20 transition-colors"
disabled={disabled}
>
Example: Weekly Sprint Championship
Sunday Showdown Series
</button>
<span className="text-xs text-gray-600"></span>
<button
type="button"
onClick={() => updateBasics({ name: 'Sunday Evening Endurance' })}
className="text-xs text-primary-blue hover:text-primary-blue/80 transition-colors"
onClick={() => updateBasics({ name: 'Midnight Endurance League' })}
className="text-xs px-2 py-0.5 rounded-full bg-primary-blue/10 text-primary-blue hover:bg-primary-blue/20 transition-colors"
disabled={disabled}
>
Example: Sunday Evening Endurance
Midnight Endurance League
</button>
<button
type="button"
onClick={() => updateBasics({ name: 'GT Masters Championship' })}
className="text-xs px-2 py-0.5 rounded-full bg-primary-blue/10 text-primary-blue hover:bg-primary-blue/20 transition-colors"
disabled={disabled}
>
GT Masters Championship
</button>
</div>
</div>
</div>
{/* Description */}
<div className="space-y-2">
{/* Description - Now Required */}
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
<FileText className="w-4 h-4 text-gray-400" />
Description (optional)
<FileText className="w-4 h-4 text-primary-blue" />
Tell your story *
</label>
<textarea
value={basics.description ?? ''}
@@ -94,87 +112,48 @@ export function LeagueBasicsSection({
}
rows={4}
disabled={disabled}
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue text-sm disabled:opacity-60 disabled:cursor-not-allowed transition-all duration-150"
placeholder="Example: A competitive sprint racing series held every Sunday at 19:00 CET. We focus on close racing and fair play. All skill levels welcome!"
className={`block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue text-sm disabled:opacity-60 disabled:cursor-not-allowed transition-all duration-150 ${
errors?.description ? 'ring-warning-amber' : 'ring-charcoal-outline'
}`}
placeholder="What makes your league special? Tell drivers what to expect..."
/>
<div className="space-y-1">
<p className="text-xs text-gray-500">
Help potential members understand your league's style, schedule, and community
{errors?.description && (
<p className="text-xs text-warning-amber flex items-center gap-1.5">
<AlertCircle className="w-3 h-3" />
{errors.description}
</p>
<div className="text-xs text-gray-500">
<span className="font-medium text-gray-400">Tip:</span> Mention your racing style, typical schedule, and skill level expectations
)}
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline/50 p-4 space-y-3">
<p className="text-xs text-gray-400">
<span className="font-medium text-gray-300">Great descriptions include:</span>
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="flex items-start gap-2">
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
<span className="text-xs text-gray-400">Racing style & pace</span>
</div>
<div className="flex items-start gap-2">
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
<span className="text-xs text-gray-400">Schedule & timezone</span>
</div>
<div className="flex items-start gap-2">
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
<span className="text-xs text-gray-400">Community vibe</span>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Visibility */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
<Globe className="w-4 h-4 text-primary-blue" />
Visibility *
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
disabled={disabled}
onClick={() =>
updateBasics({
visibility: 'public',
})
}
className={`group relative flex flex-col items-center gap-2 px-4 py-4 text-sm rounded-lg border transition-all duration-200 ${
basics.visibility === 'public'
? 'border-primary-blue bg-primary-blue/10 text-primary-blue shadow-[0_0_15px_rgba(25,140,255,0.2)]'
: 'border-charcoal-outline bg-iron-gray/50 text-gray-300 hover:border-gray-500 hover:bg-iron-gray'
} ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
>
<Globe className={`w-5 h-5 ${basics.visibility === 'public' ? 'text-primary-blue' : 'text-gray-400'}`} />
<div className="text-center">
<div className="font-medium">Public</div>
<div className="text-xs text-gray-500 mt-0.5">Anyone can join</div>
</div>
</button>
<button
type="button"
disabled={disabled}
onClick={() =>
updateBasics({
visibility: 'private',
})
}
className={`group relative flex flex-col items-center gap-2 px-4 py-4 text-sm rounded-lg border transition-all duration-200 ${
basics.visibility === 'private'
? 'border-primary-blue bg-primary-blue/10 text-primary-blue shadow-[0_0_15px_rgba(25,140,255,0.2)]'
: 'border-charcoal-outline bg-iron-gray/50 text-gray-300 hover:border-gray-500 hover:bg-iron-gray'
} ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
>
<Lock className={`w-5 h-5 ${basics.visibility === 'private' ? 'text-primary-blue' : 'text-gray-400'}`} />
<div className="text-center">
<div className="font-medium">Private</div>
<div className="text-xs text-gray-500 mt-0.5">Invite only</div>
</div>
</button>
</div>
{errors?.visibility && (
<p className="mt-1 text-xs text-warning-amber flex items-center gap-1">
<span>⚠️</span>
{errors.visibility}
</p>
)}
</div>
{/* Game */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
<Gamepad2 className="w-4 h-4 text-gray-400" />
Game platform
</label>
<div className="relative">
<Input value="iRacing" disabled />
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-500">
More platforms soon
</div>
{/* Game Platform */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
<Gamepad2 className="w-4 h-4 text-gray-400" />
Game platform
</label>
<div className="relative">
<Input value="iRacing" disabled />
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-500">
More platforms soon
</div>
</div>
</div>

View File

@@ -2,8 +2,17 @@
import Link from 'next/link';
import Image from 'next/image';
import {
Trophy,
Users,
Flag,
Award,
Gamepad2,
Calendar,
ChevronRight,
Sparkles,
} from 'lucide-react';
import type { LeagueSummaryDTO } from '@gridpilot/racing/application/dto/LeagueSummaryDTO';
import Card from '../ui/Card';
import { getLeagueCoverClasses } from '@/lib/leagueCovers';
import { getImageService } from '@/lib/di-container';
@@ -12,133 +21,214 @@ interface LeagueCardProps {
onClick?: () => void;
}
function getChampionshipIcon(type?: string) {
switch (type) {
case 'driver':
return Trophy;
case 'team':
return Users;
case 'nations':
return Flag;
case 'trophy':
return Award;
default:
return Trophy;
}
}
function getChampionshipLabel(type?: string) {
switch (type) {
case 'driver':
return 'Driver';
case 'team':
return 'Team';
case 'nations':
return 'Nations';
case 'trophy':
return 'Trophy';
default:
return 'Championship';
}
}
function getGameColor(gameId?: string): string {
switch (gameId) {
case 'iracing':
return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
case 'acc':
return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'f1-23':
case 'f1-24':
return 'bg-red-500/20 text-red-400 border-red-500/30';
default:
return 'bg-primary-blue/20 text-primary-blue border-primary-blue/30';
}
}
function isNewLeague(createdAt: Date): boolean {
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
return new Date(createdAt) > oneWeekAgo;
}
export default function LeagueCard({ league, onClick }: LeagueCardProps) {
const imageService = getImageService();
const coverUrl = imageService.getLeagueCover(league.id);
const logoUrl = imageService.getLeagueLogo(league.id);
const ChampionshipIcon = getChampionshipIcon(league.scoring?.primaryChampionshipType);
const championshipLabel = getChampionshipLabel(league.scoring?.primaryChampionshipType);
const gameColorClass = getGameColor(league.scoring?.gameId);
const isNew = isNewLeague(league.createdAt);
const isTeamLeague = league.maxTeams && league.maxTeams > 0;
// Calculate fill percentage - use teams for team leagues, drivers otherwise
const usedSlots = isTeamLeague ? (league.usedTeamSlots ?? 0) : (league.usedDriverSlots ?? 0);
const maxSlots = isTeamLeague ? (league.maxTeams ?? 0) : (league.maxDrivers ?? 0);
const fillPercentage = maxSlots > 0 ? (usedSlots / maxSlots) * 100 : 0;
const hasOpenSlots = maxSlots > 0 && usedSlots < maxSlots;
// Determine slot label based on championship type
const getSlotLabel = () => {
if (isTeamLeague) return 'Teams';
if (league.scoring?.primaryChampionshipType === 'nations') return 'Nations';
return 'Drivers';
};
const slotLabel = getSlotLabel();
return (
<div
className="cursor-pointer hover:scale-[1.03] transition-transform duration-150"
className="group relative cursor-pointer h-full"
onClick={onClick}
>
<Card>
<div className="space-y-3">
<div className={getLeagueCoverClasses(league.id)} aria-hidden="true">
<div className="relative w-full h-full">
<Image
src={coverUrl}
alt={`${league.name} cover`}
fill
className="object-cover opacity-80"
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
/>
<div className="absolute left-4 bottom-4 flex items-center">
<div className="w-10 h-10 rounded-full overflow-hidden border border-charcoal-outline/80 bg-deep-graphite/80 shadow-[0_0_10px_rgba(0,0,0,0.6)]">
<Image
src={logoUrl}
alt={`${league.name} logo`}
width={40}
height={40}
className="w-full h-full object-cover"
/>
</div>
</div>
</div>
{/* Card Container */}
<div className="relative h-full rounded-xl bg-iron-gray border border-charcoal-outline overflow-hidden transition-all duration-200 hover:border-primary-blue/50 hover:shadow-[0_0_30px_rgba(25,140,255,0.15)] hover:bg-iron-gray/80">
{/* Cover Image */}
<div className="relative h-32 overflow-hidden">
<Image
src={coverUrl}
alt={`${league.name} cover`}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
/>
{/* Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-iron-gray via-iron-gray/60 to-transparent" />
{/* Badges - Top Left */}
<div className="absolute top-3 left-3 flex items-center gap-2">
{isNew && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-performance-green/20 text-performance-green border border-performance-green/30">
<Sparkles className="w-3 h-3" />
NEW
</span>
)}
{league.scoring?.gameName && (
<span className={`px-2 py-0.5 rounded-full text-[10px] font-semibold border ${gameColorClass}`}>
{league.scoring.gameName}
</span>
)}
</div>
<div className="flex items-start justify-between">
<h3 className="text-xl font-semibold text-white">{league.name}</h3>
<span className="text-xs text-gray-500">
{new Date(league.createdAt).toLocaleDateString()}
{/* Championship Type Badge - Top Right */}
<div className="absolute top-3 right-3">
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-deep-graphite/80 text-gray-300 border border-charcoal-outline">
<ChampionshipIcon className="w-3 h-3" />
{championshipLabel}
</span>
</div>
<p className="text-gray-400 text-sm line-clamp-2">
{league.description}
</p>
{league.structureSummary && (
<p className="text-xs text-gray-400">
{league.structureSummary}
</p>
)}
{league.scoringPatternSummary && (
<p className="text-xs text-gray-400">
{league.scoringPatternSummary}
</p>
)}
{league.timingSummary && (
<p className="text-xs text-gray-400">
{league.timingSummary}
</p>
)}
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<div className="flex flex-col text-xs text-gray-500">
<span>
Owner:{' '}
<Link
href={`/drivers/${league.ownerId}?from=league&leagueId=${league.id}`}
className="text-primary-blue hover:underline"
>
{league.ownerId.slice(0, 8)}...
</Link>
</span>
<span className="mt-1 text-gray-400">
Drivers:{' '}
<span className="text-white font-medium">
{typeof league.usedDriverSlots === 'number'
? league.usedDriverSlots
: '—'}
</span>
{' / '}
<span className="text-gray-300">
{league.maxDrivers ?? '—'}
</span>
</span>
{typeof league.usedTeamSlots === 'number' ||
typeof league.maxTeams === 'number' ? (
<span className="mt-0.5 text-gray-400">
Teams:{' '}
<span className="text-white font-medium">
{typeof league.usedTeamSlots === 'number'
? league.usedTeamSlots
: '—'}
</span>
{' / '}
<span className="text-gray-300">
{league.maxTeams ?? '—'}
</span>
</span>
) : null}
</div>
<div className="flex flex-col items-end text-xs text-gray-400">
{league.scoring ? (
<>
<span className="text-primary-blue font-semibold">
{league.scoring.gameName}
</span>
<span className="mt-0.5">
{league.scoring.primaryChampionshipType === 'driver'
? 'Driver championship'
: league.scoring.primaryChampionshipType === 'team'
? 'Team championship'
: league.scoring.primaryChampionshipType === 'nations'
? 'Nations championship'
: 'Trophy championship'}
</span>
<span className="mt-0.5">
{league.scoring.scoringPatternSummary}
</span>
</>
) : (
<span className="text-gray-500">Scoring: Not configured</span>
)}
{/* Logo */}
<div className="absolute left-4 -bottom-6 z-10">
<div className="w-12 h-12 rounded-lg overflow-hidden border-2 border-iron-gray bg-deep-graphite shadow-xl">
<Image
src={logoUrl}
alt={`${league.name} logo`}
width={48}
height={48}
className="w-full h-full object-cover"
/>
</div>
</div>
</div>
</Card>
{/* Content */}
<div className="pt-8 px-4 pb-4 flex flex-col flex-1">
{/* Title & Description */}
<h3 className="text-base font-semibold text-white mb-1 line-clamp-1 group-hover:text-primary-blue transition-colors">
{league.name}
</h3>
<p className="text-xs text-gray-500 line-clamp-2 mb-3 h-8">
{league.description || 'No description available'}
</p>
{/* Stats Row */}
<div className="flex items-center gap-3 mb-3">
{/* Primary Slots (Drivers/Teams/Nations) */}
<div className="flex-1">
<div className="flex items-center justify-between text-[10px] text-gray-500 mb-1">
<span>{slotLabel}</span>
<span className="text-gray-400">
{usedSlots}/{maxSlots || '∞'}
</span>
</div>
<div className="h-1.5 rounded-full bg-charcoal-outline overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
fillPercentage >= 90
? 'bg-warning-amber'
: fillPercentage >= 70
? 'bg-primary-blue'
: 'bg-performance-green'
}`}
style={{ width: `${Math.min(fillPercentage, 100)}%` }}
/>
</div>
</div>
{/* Open Slots Badge */}
{hasOpenSlots && (
<div className="flex items-center gap-1 px-2 py-1 rounded-lg bg-neon-aqua/10 border border-neon-aqua/20">
<span className="w-1.5 h-1.5 rounded-full bg-neon-aqua animate-pulse" />
<span className="text-[10px] text-neon-aqua font-medium">
{maxSlots - usedSlots} open
</span>
</div>
)}
</div>
{/* Driver count for team leagues */}
{isTeamLeague && (
<div className="flex items-center gap-2 mb-3 text-[10px] text-gray-500">
<Users className="w-3 h-3" />
<span>
{league.usedDriverSlots ?? 0}/{league.maxDrivers ?? '∞'} drivers
</span>
</div>
)}
{/* Spacer to push footer to bottom */}
<div className="flex-1" />
{/* Footer Info */}
<div className="flex items-center justify-between pt-3 border-t border-charcoal-outline/50 mt-auto">
<div className="flex items-center gap-3 text-[10px] text-gray-500">
{league.timingSummary && (
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{league.timingSummary.split('•')[1]?.trim() || league.timingSummary}
</span>
)}
</div>
{/* View Arrow */}
<div className="flex items-center gap-1 text-[10px] text-gray-500 group-hover:text-primary-blue transition-colors">
<span>View</span>
<ChevronRight className="w-3 h-3 transition-transform group-hover:translate-x-0.5" />
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -362,13 +362,13 @@ export function LeagueDropSection({
const isSelected = dropPolicy.strategy === option.value;
const ruleInfo = DROP_RULE_INFO[option.value];
return (
<div key={option.value} className="relative">
<div key={option.value} className="relative flex items-center">
<button
type="button"
disabled={disabled}
onClick={() => handleStrategyChange(option.value)}
className={`
flex items-center gap-2 px-3 py-2 rounded-lg border-2 transition-all duration-200
flex items-center gap-2 px-3 py-2 rounded-l-lg border-2 border-r-0 transition-all duration-200
${isSelected
? 'border-primary-blue bg-primary-blue/10'
: 'border-charcoal-outline/40 bg-iron-gray/20 hover:border-charcoal-outline hover:bg-iron-gray/30'
@@ -388,19 +388,26 @@ export function LeagueDropSection({
<span className={`text-sm font-medium ${isSelected ? 'text-white' : 'text-gray-400'}`}>
{option.label}
</span>
</button>
{/* Info button */}
<button
ref={(el) => { dropRuleRefs.current[option.value] = el; }}
type="button"
onClick={(e) => {
e.stopPropagation();
setActiveDropRuleFlyout(activeDropRuleFlyout === option.value ? null : option.value);
}}
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors shrink-0 ml-1"
>
<HelpCircle className="w-3 h-3" />
</button>
{/* Info button - separate from main button */}
<button
ref={(el) => { dropRuleRefs.current[option.value] = el; }}
type="button"
onClick={(e) => {
e.stopPropagation();
setActiveDropRuleFlyout(activeDropRuleFlyout === option.value ? null : option.value);
}}
className={`
flex h-full items-center justify-center px-2 py-2 rounded-r-lg border-2 border-l-0 transition-all duration-200
${isSelected
? 'border-primary-blue bg-primary-blue/10'
: 'border-charcoal-outline/40 bg-iron-gray/20 hover:border-charcoal-outline hover:bg-iron-gray/30'
}
${disabled ? 'cursor-default opacity-60' : 'cursor-pointer'}
`}
>
<HelpCircle className="w-3.5 h-3.5 text-gray-500 hover:text-primary-blue transition-colors" />
</button>
{/* Drop Rule Info Flyout */}

View File

@@ -161,8 +161,12 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
return '🏁';
};
const visibilityIcon = basics.visibility === 'public' ? Eye : EyeOff;
const visibilityLabel = basics.visibility === 'public' ? 'Public' : 'Private';
// Normalize visibility to new terminology
const isRanked = basics.visibility === 'ranked' || basics.visibility === 'public';
const visibilityLabel = isRanked ? 'Ranked' : 'Unranked';
const visibilityDescription = isRanked
? 'Competitive • Affects ratings'
: 'Casual • Friends only';
// Calculate total weekend duration
const totalWeekendMinutes = (timings.practiceMinutes ?? 0) +
@@ -190,13 +194,15 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
{basics.description || 'Ready to launch your racing series!'}
</p>
<div className="flex flex-wrap items-center gap-3">
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${
basics.visibility === 'public'
? 'bg-performance-green/10 text-performance-green'
: 'bg-warning-amber/10 text-warning-amber'
{/* Ranked/Unranked Badge */}
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium ${
isRanked
? 'bg-primary-blue/15 text-primary-blue border border-primary-blue/30'
: 'bg-neon-aqua/15 text-neon-aqua border border-neon-aqua/30'
}`}>
{basics.visibility === 'public' ? <Eye className="w-3 h-3" /> : <EyeOff className="w-3 h-3" />}
{visibilityLabel}
{isRanked ? <Trophy className="w-3 h-3" /> : <Users className="w-3 h-3" />}
<span className="font-semibold">{visibilityLabel}</span>
<span className="text-[10px] opacity-70"> {visibilityDescription}</span>
</span>
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
<Gamepad2 className="w-3 h-3" />

View File

@@ -1,9 +1,110 @@
'use client';
import { User, Users2, Info } from 'lucide-react';
import { User, Users2, Info, Check, HelpCircle, X } from 'lucide-react';
import { useState, useRef, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import Input from '@/components/ui/Input';
import SegmentedControl from '@/components/ui/SegmentedControl';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import { GameConstraints } from '@gridpilot/racing/domain/value-objects/GameConstraints';
// ============================================================================
// INFO FLYOUT COMPONENT
// ============================================================================
interface InfoFlyoutProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
anchorRef: React.RefObject<HTMLElement | null>;
}
function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutProps) {
const [position, setPosition] = useState({ top: 0, left: 0 });
const [mounted, setMounted] = useState(false);
const flyoutRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isOpen && anchorRef.current && mounted) {
const rect = anchorRef.current.getBoundingClientRect();
const flyoutWidth = Math.min(320, window.innerWidth - 40);
const flyoutHeight = 300;
const padding = 16;
let left = rect.right + 12;
let top = rect.top;
if (left + flyoutWidth > window.innerWidth - padding) {
left = rect.left - flyoutWidth - 12;
}
if (left < padding) {
left = Math.max(padding, (window.innerWidth - flyoutWidth) / 2);
}
top = rect.top - flyoutHeight / 3;
if (top + flyoutHeight > window.innerHeight - padding) {
top = window.innerHeight - flyoutHeight - padding;
}
if (top < padding) top = padding;
left = Math.max(padding, Math.min(left, window.innerWidth - flyoutWidth - padding));
setPosition({ top, left });
}
}, [isOpen, anchorRef, mounted]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const handleClickOutside = (e: MouseEvent) => {
if (flyoutRef.current && !flyoutRef.current.contains(e.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
if (!isOpen || !mounted) return null;
return createPortal(
<div
ref={flyoutRef}
className="fixed z-50 w-[320px] max-h-[80vh] overflow-y-auto bg-iron-gray border border-charcoal-outline rounded-xl shadow-2xl animate-fade-in"
style={{ top: position.top, left: position.left }}
>
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline/50 sticky top-0 bg-iron-gray z-10">
<div className="flex items-center gap-2">
<HelpCircle className="w-4 h-4 text-primary-blue" />
<span className="text-sm font-semibold text-white">{title}</span>
</div>
<button
type="button"
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-charcoal-outline transition-colors"
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
<div className="p-4">
{children}
</div>
</div>,
document.body
);
}
interface LeagueStructureSectionProps {
form: LeagueConfigFormModel;
@@ -127,123 +228,368 @@ export function LeagueStructureSection({
});
};
// Flyout state
const [showSoloFlyout, setShowSoloFlyout] = useState(false);
const [showTeamsFlyout, setShowTeamsFlyout] = useState(false);
const soloInfoRef = useRef<HTMLButtonElement>(null);
const teamsInfoRef = useRef<HTMLButtonElement>(null);
const isSolo = structure.mode === 'solo';
// Get game-specific constraints
const gameConstraints = useMemo(
() => GameConstraints.forGame(form.basics.gameId),
[form.basics.gameId]
);
return (
<div className="space-y-6">
{/* League structure selection */}
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
<Users2 className="w-4 h-4 text-primary-blue" />
League structure
</label>
<SegmentedControl
options={[
{
value: 'solo',
label: 'Drivers only (Solo)',
description: 'Individual drivers score points.',
},
{
value: 'fixedTeams',
label: 'Teams',
description: 'Teams with fixed drivers per team.',
},
]}
value={structure.mode}
onChange={
disabled
? undefined
: (mode) => handleModeChange(mode as 'solo' | 'fixedTeams')
}
/>
<div className="space-y-8">
{/* Emotional header */}
<div className="text-center pb-2">
<h3 className="text-lg font-semibold text-white mb-2">
How will your drivers compete?
</h3>
<p className="text-sm text-gray-400 max-w-lg mx-auto">
Choose your championship format individual glory or team triumph.
</p>
</div>
{/* Solo mode capacity */}
{structure.mode === 'solo' && (
<div className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-5 space-y-4">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10 shrink-0">
<User className="w-5 h-5 text-primary-blue" />
{/* Mode Selection Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Solo Mode Card */}
<div className="relative">
<button
type="button"
disabled={disabled}
onClick={() => handleModeChange('solo')}
className={`w-full group relative flex flex-col gap-4 p-6 text-left rounded-xl border-2 transition-all duration-200 ${
isSolo
? 'border-primary-blue bg-gradient-to-br from-primary-blue/15 to-primary-blue/5 shadow-[0_0_30px_rgba(25,140,255,0.25)]'
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500 hover:bg-iron-gray/50'
} ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
>
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${
isSolo ? 'bg-primary-blue/30' : 'bg-charcoal-outline/50'
}`}>
<User className={`w-6 h-6 ${isSolo ? 'text-primary-blue' : 'text-gray-400'}`} />
</div>
<div>
<div className={`text-lg font-bold ${isSolo ? 'text-white' : 'text-gray-300'}`}>
Solo Drivers
</div>
<div className={`text-xs ${isSolo ? 'text-primary-blue' : 'text-gray-500'}`}>
Individual competition
</div>
</div>
</div>
{/* Radio indicator */}
<div className={`flex h-6 w-6 items-center justify-center rounded-full border-2 shrink-0 transition-colors ${
isSolo ? 'border-primary-blue bg-primary-blue' : 'border-gray-500'
}`}>
{isSolo && <Check className="w-3.5 h-3.5 text-white" />}
</div>
</div>
<div className="flex-1">
<h3 className="text-sm font-semibold text-white mb-1">Driver capacity</h3>
<p className="text-xs text-gray-500">
Set the maximum number of drivers who can join your league
</p>
{/* Emotional tagline */}
<p className={`text-sm ${isSolo ? 'text-gray-300' : 'text-gray-500'}`}>
Every driver for themselves. Pure skill, pure competition, pure glory.
</p>
{/* Features */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs text-gray-400">
<Check className={`w-3.5 h-3.5 ${isSolo ? 'text-performance-green' : 'text-gray-500'}`} />
<span>Individual driver standings</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-400">
<Check className={`w-3.5 h-3.5 ${isSolo ? 'text-performance-green' : 'text-gray-500'}`} />
<span>Simple, classic format</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-400">
<Check className={`w-3.5 h-3.5 ${isSolo ? 'text-performance-green' : 'text-gray-500'}`} />
<span>Perfect for any grid size</span>
</div>
</div>
</button>
{/* Info button */}
<button
ref={soloInfoRef}
type="button"
onClick={() => setShowSoloFlyout(true)}
className="absolute top-2 right-2 flex h-6 w-6 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors"
>
<HelpCircle className="w-4 h-4" />
</button>
</div>
{/* Solo Info Flyout */}
<InfoFlyout
isOpen={showSoloFlyout}
onClose={() => setShowSoloFlyout(false)}
title="Solo Drivers Mode"
anchorRef={soloInfoRef}
>
<div className="space-y-4">
<p className="text-xs text-gray-400">
In solo mode, each driver competes individually. Points are awarded
based on finishing position, and standings track individual performance.
</p>
<div className="space-y-2">
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Best For</div>
<ul className="space-y-1.5">
<li className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
<span>Traditional racing championships</span>
</li>
<li className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
<span>Smaller grids (10-30 drivers)</span>
</li>
<li className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
<span>Quick setup with minimal coordination</span>
</li>
</ul>
</div>
</div>
</InfoFlyout>
<div className="space-y-4">
{/* Teams Mode Card */}
<div className="relative">
<button
type="button"
disabled={disabled}
onClick={() => handleModeChange('fixedTeams')}
className={`w-full group relative flex flex-col gap-4 p-6 text-left rounded-xl border-2 transition-all duration-200 ${
!isSolo
? 'border-neon-aqua bg-gradient-to-br from-neon-aqua/15 to-neon-aqua/5 shadow-[0_0_30px_rgba(67,201,230,0.2)]'
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500 hover:bg-iron-gray/50'
} ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
>
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${
!isSolo ? 'bg-neon-aqua/30' : 'bg-charcoal-outline/50'
}`}>
<Users2 className={`w-6 h-6 ${!isSolo ? 'text-neon-aqua' : 'text-gray-400'}`} />
</div>
<div>
<div className={`text-lg font-bold ${!isSolo ? 'text-white' : 'text-gray-300'}`}>
Team Racing
</div>
<div className={`text-xs ${!isSolo ? 'text-neon-aqua' : 'text-gray-500'}`}>
Shared destiny
</div>
</div>
</div>
{/* Radio indicator */}
<div className={`flex h-6 w-6 items-center justify-center rounded-full border-2 shrink-0 transition-colors ${
!isSolo ? 'border-neon-aqua bg-neon-aqua' : 'border-gray-500'
}`}>
{!isSolo && <Check className="w-3.5 h-3.5 text-deep-graphite" />}
</div>
</div>
{/* Emotional tagline */}
<p className={`text-sm ${!isSolo ? 'text-gray-300' : 'text-gray-500'}`}>
Victory is sweeter together. Build a team, share the podium.
</p>
{/* Features */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs text-gray-400">
<Check className={`w-3.5 h-3.5 ${!isSolo ? 'text-neon-aqua' : 'text-gray-500'}`} />
<span>Team & driver standings</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-400">
<Check className={`w-3.5 h-3.5 ${!isSolo ? 'text-neon-aqua' : 'text-gray-500'}`} />
<span>Fixed roster per team</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-400">
<Check className={`w-3.5 h-3.5 ${!isSolo ? 'text-neon-aqua' : 'text-gray-500'}`} />
<span>Great for endurance & pro-am</span>
</div>
</div>
</button>
{/* Info button */}
<button
ref={teamsInfoRef}
type="button"
onClick={() => setShowTeamsFlyout(true)}
className="absolute top-2 right-2 flex h-6 w-6 items-center justify-center rounded-full text-gray-500 hover:text-neon-aqua hover:bg-neon-aqua/10 transition-colors"
>
<HelpCircle className="w-4 h-4" />
</button>
</div>
{/* Teams Info Flyout */}
<InfoFlyout
isOpen={showTeamsFlyout}
onClose={() => setShowTeamsFlyout(false)}
title="Team Racing Mode"
anchorRef={teamsInfoRef}
>
<div className="space-y-4">
<p className="text-xs text-gray-400">
In team mode, drivers are grouped into fixed teams. Points contribute to
both individual and team standings, creating deeper competition.
</p>
<div className="space-y-2">
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Best For</div>
<ul className="space-y-1.5">
<li className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
<span>Endurance races with driver swaps</span>
</li>
<li className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
<span>Pro-Am style competitions</span>
</li>
<li className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
<span>Larger organized leagues</span>
</li>
</ul>
</div>
</div>
</InfoFlyout>
</div>
{/* Configuration Panel */}
<div className="rounded-xl border border-charcoal-outline bg-gradient-to-b from-iron-gray/50 to-iron-gray/30 p-6 space-y-5">
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${
isSolo ? 'bg-primary-blue/20' : 'bg-neon-aqua/20'
}`}>
{isSolo ? (
<User className="w-5 h-5 text-primary-blue" />
) : (
<Users2 className="w-5 h-5 text-neon-aqua" />
)}
</div>
<div>
<h3 className="text-sm font-semibold text-white">
{isSolo ? 'Grid size' : 'Team configuration'}
</h3>
<p className="text-xs text-gray-500">
{isSolo
? 'How many drivers can join your championship?'
: 'Configure teams and roster sizes'
}
</p>
</div>
</div>
{/* Solo mode capacity */}
{isSolo && (
<div className="space-y-4">
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-300">
Max drivers
Maximum drivers
</label>
<Input
type="number"
value={structure.maxDrivers ?? 24}
value={structure.maxDrivers ?? gameConstraints.defaultMaxDrivers}
onChange={(e) => handleMaxDriversChange(e.target.value)}
disabled={disabled}
min={1}
max={64}
className="w-32"
min={gameConstraints.minDrivers}
max={gameConstraints.maxDrivers}
className="w-40"
/>
<div className="space-y-2">
<p className="text-xs text-gray-500 flex items-start gap-1.5">
<Info className="w-3 h-3 mt-0.5 shrink-0" />
<span>Typical club leagues use 2030 drivers</span>
</p>
<div className="flex flex-wrap gap-2 text-xs">
<button
type="button"
onClick={() => handleMaxDriversChange('20')}
disabled={disabled}
className="px-2 py-1 rounded bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue transition-colors"
>
Small (20)
</button>
<button
type="button"
onClick={() => handleMaxDriversChange('24')}
disabled={disabled}
className="px-2 py-1 rounded bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue transition-colors"
>
Medium (24)
</button>
<p className="text-xs text-gray-500">
{form.basics.gameId.toUpperCase()} supports up to {gameConstraints.maxDrivers} drivers
</p>
</div>
<div className="space-y-2">
<p className="text-xs text-gray-500">Quick select:</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => handleMaxDriversChange('16')}
disabled={disabled || 16 > gameConstraints.maxDrivers}
className={`px-3 py-2 rounded-lg text-xs font-medium transition-all ${
structure.maxDrivers === 16
? 'bg-primary-blue text-white'
: 'bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue disabled:opacity-40 disabled:cursor-not-allowed'
}`}
>
Compact (16)
</button>
<button
type="button"
onClick={() => handleMaxDriversChange('24')}
disabled={disabled || 24 > gameConstraints.maxDrivers}
className={`px-3 py-2 rounded-lg text-xs font-medium transition-all ${
structure.maxDrivers === 24
? 'bg-primary-blue text-white'
: 'bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue disabled:opacity-40 disabled:cursor-not-allowed'
}`}
>
Standard (24)
</button>
{gameConstraints.maxDrivers >= 30 && (
<button
type="button"
onClick={() => handleMaxDriversChange('30')}
disabled={disabled}
className="px-2 py-1 rounded bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue transition-colors"
className={`px-3 py-2 rounded-lg text-xs font-medium transition-all ${
structure.maxDrivers === 30
? 'bg-primary-blue text-white'
: 'bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue'
}`}
>
Large (30)
Full Grid (30)
</button>
</div>
)}
{gameConstraints.maxDrivers >= 40 && (
<button
type="button"
onClick={() => handleMaxDriversChange('40')}
disabled={disabled}
className={`px-3 py-2 rounded-lg text-xs font-medium transition-all ${
structure.maxDrivers === 40
? 'bg-primary-blue text-white'
: 'bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue'
}`}
>
Large (40)
</button>
)}
{gameConstraints.maxDrivers >= 64 && (
<button
type="button"
onClick={() => handleMaxDriversChange(String(gameConstraints.maxDrivers))}
disabled={disabled}
className={`px-3 py-2 rounded-lg text-xs font-medium transition-all ${
structure.maxDrivers === gameConstraints.maxDrivers
? 'bg-primary-blue text-white'
: 'bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue'
}`}
>
Max ({gameConstraints.maxDrivers})
</button>
)}
</div>
</div>
</div>
</div>
)}
)}
{/* Teams mode capacity */}
{structure.mode === 'fixedTeams' && (
<div className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-5 space-y-4">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10 shrink-0">
<Users2 className="w-5 h-5 text-primary-blue" />
</div>
<div className="flex-1">
<h3 className="text-sm font-semibold text-white mb-1">Team structure</h3>
<p className="text-xs text-gray-500">
Configure the team composition and maximum grid size
</p>
</div>
</div>
<div className="space-y-4">
<div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-3">
<p className="text-xs text-gray-300">
<span className="font-medium text-primary-blue">Quick setup:</span> Choose a common configuration
</p>
<div className="flex flex-wrap gap-2 mt-2">
{/* Teams mode capacity */}
{!isSolo && (
<div className="space-y-5">
{/* Quick presets */}
<div className="space-y-3">
<p className="text-xs text-gray-500">Popular configurations:</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => {
@@ -251,9 +597,13 @@ export function LeagueStructureSection({
handleDriversPerTeamChange('2');
}}
disabled={disabled}
className="px-3 py-1.5 rounded bg-iron-gray border border-charcoal-outline text-xs text-gray-300 hover:border-primary-blue hover:text-primary-blue transition-colors"
className={`px-3 py-2 rounded-lg text-xs font-medium transition-all ${
structure.maxTeams === 10 && structure.driversPerTeam === 2
? 'bg-neon-aqua text-deep-graphite'
: 'bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-neon-aqua hover:text-neon-aqua'
}`}
>
10 teams × 2 drivers (20 grid)
10 × 2 (20 grid)
</button>
<button
type="button"
@@ -262,9 +612,13 @@ export function LeagueStructureSection({
handleDriversPerTeamChange('2');
}}
disabled={disabled}
className="px-3 py-1.5 rounded bg-iron-gray border border-charcoal-outline text-xs text-gray-300 hover:border-primary-blue hover:text-primary-blue transition-colors"
className={`px-3 py-2 rounded-lg text-xs font-medium transition-all ${
structure.maxTeams === 12 && structure.driversPerTeam === 2
? 'bg-neon-aqua text-deep-graphite'
: 'bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-neon-aqua hover:text-neon-aqua'
}`}
>
12 teams × 2 drivers (24 grid)
12 × 2 (24 grid)
</button>
<button
type="button"
@@ -273,17 +627,37 @@ export function LeagueStructureSection({
handleDriversPerTeamChange('3');
}}
disabled={disabled}
className="px-3 py-1.5 rounded bg-iron-gray border border-charcoal-outline text-xs text-gray-300 hover:border-primary-blue hover:text-primary-blue transition-colors"
className={`px-3 py-2 rounded-lg text-xs font-medium transition-all ${
structure.maxTeams === 8 && structure.driversPerTeam === 3
? 'bg-neon-aqua text-deep-graphite'
: 'bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-neon-aqua hover:text-neon-aqua'
}`}
>
8 teams × 3 drivers (24 grid)
8 × 3 (24 grid)
</button>
<button
type="button"
onClick={() => {
handleMaxTeamsChange('15');
handleDriversPerTeamChange('2');
}}
disabled={disabled}
className={`px-3 py-2 rounded-lg text-xs font-medium transition-all ${
structure.maxTeams === 15 && structure.driversPerTeam === 2
? 'bg-neon-aqua text-deep-graphite'
: 'bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-neon-aqua hover:text-neon-aqua'
}`}
>
15 × 2 (30 grid)
</button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Manual configuration */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-2 border-t border-charcoal-outline/50">
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-300">
Max teams
Teams
</label>
<Input
type="number"
@@ -292,17 +666,13 @@ export function LeagueStructureSection({
disabled={disabled}
min={1}
max={32}
className="w-32"
className="w-full"
/>
<p className="text-xs text-gray-500 flex items-start gap-1.5">
<Info className="w-3 h-3 mt-0.5 shrink-0" />
<span>Total competing teams</span>
</p>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-300">
Drivers per team
Drivers / team
</label>
<Input
type="number"
@@ -311,36 +681,27 @@ export function LeagueStructureSection({
disabled={disabled}
min={1}
max={6}
className="w-32"
className="w-full"
/>
<p className="text-xs text-gray-500 flex items-start gap-1.5">
<Info className="w-3 h-3 mt-0.5 shrink-0" />
<span>Common: 23 drivers</span>
</p>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-300">
Total grid size
Total grid
</label>
<div className="flex items-center gap-2">
<Input
type="number"
value={structure.maxDrivers ?? 0}
disabled
className="w-32"
/>
<div className="text-xs text-gray-500">drivers</div>
<div className={`flex items-center justify-center h-10 rounded-lg border ${
!isSolo ? 'bg-neon-aqua/10 border-neon-aqua/30' : 'bg-iron-gray border-charcoal-outline'
}`}>
<span className={`text-lg font-bold ${!isSolo ? 'text-neon-aqua' : 'text-gray-400'}`}>
{structure.maxDrivers ?? 0}
</span>
<span className="text-xs text-gray-500 ml-1">drivers</span>
</div>
<p className="text-xs text-gray-500 flex items-start gap-1.5">
<Info className="w-3 h-3 mt-0.5 shrink-0" />
<span>Auto-calculated from teams × drivers</span>
</p>
</div>
</div>
</div>
</div>
)}
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,460 @@
'use client';
import { Trophy, Users, Check, HelpCircle, X } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
// Minimum drivers for ranked leagues
const MIN_RANKED_DRIVERS = 10;
// ============================================================================
// INFO FLYOUT COMPONENT
// ============================================================================
interface InfoFlyoutProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
anchorRef: React.RefObject<HTMLElement | null>;
}
function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutProps) {
const [position, setPosition] = useState({ top: 0, left: 0 });
const [mounted, setMounted] = useState(false);
const flyoutRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isOpen && anchorRef.current && mounted) {
const rect = anchorRef.current.getBoundingClientRect();
const flyoutWidth = Math.min(340, window.innerWidth - 40);
const flyoutHeight = 350;
const padding = 16;
let left = rect.right + 12;
let top = rect.top;
if (left + flyoutWidth > window.innerWidth - padding) {
left = rect.left - flyoutWidth - 12;
}
if (left < padding) {
left = Math.max(padding, (window.innerWidth - flyoutWidth) / 2);
}
top = rect.top - flyoutHeight / 3;
if (top + flyoutHeight > window.innerHeight - padding) {
top = window.innerHeight - flyoutHeight - padding;
}
if (top < padding) top = padding;
left = Math.max(padding, Math.min(left, window.innerWidth - flyoutWidth - padding));
setPosition({ top, left });
}
}, [isOpen, anchorRef, mounted]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const handleClickOutside = (e: MouseEvent) => {
if (flyoutRef.current && !flyoutRef.current.contains(e.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
if (!isOpen || !mounted) return null;
return createPortal(
<div
ref={flyoutRef}
className="fixed z-50 w-[340px] max-h-[80vh] overflow-y-auto bg-iron-gray border border-charcoal-outline rounded-xl shadow-2xl animate-fade-in"
style={{ top: position.top, left: position.left }}
>
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline/50 sticky top-0 bg-iron-gray z-10">
<div className="flex items-center gap-2">
<HelpCircle className="w-4 h-4 text-primary-blue" />
<span className="text-sm font-semibold text-white">{title}</span>
</div>
<button
type="button"
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-charcoal-outline transition-colors"
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
<div className="p-4">
{children}
</div>
</div>,
document.body
);
}
interface LeagueVisibilitySectionProps {
form: LeagueConfigFormModel;
onChange?: (form: LeagueConfigFormModel) => void;
errors?: {
visibility?: string;
};
readOnly?: boolean;
}
export function LeagueVisibilitySection({
form,
onChange,
errors,
readOnly,
}: LeagueVisibilitySectionProps) {
const basics = form.basics;
const disabled = readOnly || !onChange;
// Flyout state
const [showRankedFlyout, setShowRankedFlyout] = useState(false);
const [showUnrankedFlyout, setShowUnrankedFlyout] = useState(false);
const rankedInfoRef = useRef<HTMLButtonElement>(null);
const unrankedInfoRef = useRef<HTMLButtonElement>(null);
// Normalize visibility to new terminology
const isRanked = basics.visibility === 'ranked' || basics.visibility === 'public';
// Auto-update minDrivers when switching to ranked
const handleVisibilityChange = (visibility: 'ranked' | 'unranked') => {
if (!onChange) return;
// If switching to ranked and current maxDrivers is below minimum, update it
if (visibility === 'ranked' && form.structure.maxDrivers < MIN_RANKED_DRIVERS) {
onChange({
...form,
basics: { ...form.basics, visibility },
structure: { ...form.structure, maxDrivers: MIN_RANKED_DRIVERS },
});
} else {
onChange({
...form,
basics: { ...form.basics, visibility },
});
}
};
return (
<div className="space-y-8">
{/* Emotional header for the step */}
<div className="text-center pb-2">
<h3 className="text-lg font-semibold text-white mb-2">
Choose your league's destiny
</h3>
<p className="text-sm text-gray-400 max-w-lg mx-auto">
Will you compete for glory on the global leaderboards, or race with friends in a private series?
</p>
</div>
{/* League Type Selection */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Ranked (Public) Option */}
<div className="relative">
<button
type="button"
disabled={disabled}
onClick={() => handleVisibilityChange('ranked')}
className={`w-full group relative flex flex-col gap-4 p-6 text-left rounded-xl border-2 transition-all duration-200 ${
isRanked
? 'border-primary-blue bg-gradient-to-br from-primary-blue/15 to-primary-blue/5 shadow-[0_0_30px_rgba(25,140,255,0.25)]'
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500 hover:bg-iron-gray/50'
} ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
>
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`flex h-14 w-14 items-center justify-center rounded-xl ${
isRanked ? 'bg-primary-blue/30' : 'bg-charcoal-outline/50'
}`}>
<Trophy className={`w-7 h-7 ${isRanked ? 'text-primary-blue' : 'text-gray-400'}`} />
</div>
<div>
<div className={`text-xl font-bold ${isRanked ? 'text-white' : 'text-gray-300'}`}>
Ranked
</div>
<div className={`text-sm ${isRanked ? 'text-primary-blue' : 'text-gray-500'}`}>
Compete for glory
</div>
</div>
</div>
{/* Radio indicator */}
<div className={`flex h-7 w-7 items-center justify-center rounded-full border-2 shrink-0 transition-colors ${
isRanked ? 'border-primary-blue bg-primary-blue' : 'border-gray-500'
}`}>
{isRanked && <Check className="w-4 h-4 text-white" />}
</div>
</div>
{/* Emotional tagline */}
<p className={`text-sm ${isRanked ? 'text-gray-300' : 'text-gray-500'}`}>
Your results matter. Build your reputation in the global standings and climb the ranks.
</p>
{/* Features */}
<div className="space-y-2.5 py-2">
<div className="flex items-center gap-2 text-sm text-gray-400">
<Check className="w-4 h-4 text-performance-green" />
<span>Discoverable by all drivers</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<Check className="w-4 h-4 text-performance-green" />
<span>Affects driver ratings & rankings</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<Check className="w-4 h-4 text-performance-green" />
<span>Featured on leaderboards</span>
</div>
</div>
{/* Requirement badge */}
<div className="flex items-center gap-2 mt-auto px-3 py-2 rounded-lg bg-warning-amber/10 border border-warning-amber/20 w-fit">
<Users className="w-4 h-4 text-warning-amber" />
<span className="text-xs text-warning-amber font-medium">
Requires {MIN_RANKED_DRIVERS}+ drivers for competitive integrity
</span>
</div>
</button>
{/* Info button */}
<button
ref={rankedInfoRef}
type="button"
onClick={() => setShowRankedFlyout(true)}
className="absolute top-3 right-3 flex h-7 w-7 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors"
>
<HelpCircle className="w-4 h-4" />
</button>
</div>
{/* Ranked Info Flyout */}
<InfoFlyout
isOpen={showRankedFlyout}
onClose={() => setShowRankedFlyout(false)}
title="Ranked Leagues"
anchorRef={rankedInfoRef}
>
<div className="space-y-4">
<p className="text-xs text-gray-400">
Ranked leagues are competitive series where results matter. Your performance
affects your driver rating and contributes to global leaderboards.
</p>
<div className="space-y-2">
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Requirements</div>
<ul className="space-y-1.5">
<li className="flex items-start gap-2 text-xs text-gray-400">
<Users className="w-3.5 h-3.5 text-warning-amber shrink-0 mt-0.5" />
<span><strong className="text-white">Minimum {MIN_RANKED_DRIVERS} drivers</strong> for competitive integrity</span>
</li>
<li className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
<span>Anyone can discover and join your league</span>
</li>
</ul>
</div>
<div className="space-y-2">
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Benefits</div>
<ul className="space-y-1.5">
<li className="flex items-start gap-2 text-xs text-gray-400">
<Trophy className="w-3.5 h-3.5 text-primary-blue shrink-0 mt-0.5" />
<span>Results affect driver ratings and rankings</span>
</li>
<li className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
<span>Featured in league discovery</span>
</li>
</ul>
</div>
</div>
</InfoFlyout>
{/* Unranked (Private) Option */}
<div className="relative">
<button
type="button"
disabled={disabled}
onClick={() => handleVisibilityChange('unranked')}
className={`w-full group relative flex flex-col gap-4 p-6 text-left rounded-xl border-2 transition-all duration-200 ${
!isRanked
? 'border-neon-aqua bg-gradient-to-br from-neon-aqua/15 to-neon-aqua/5 shadow-[0_0_30px_rgba(67,201,230,0.2)]'
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500 hover:bg-iron-gray/50'
} ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
>
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`flex h-14 w-14 items-center justify-center rounded-xl ${
!isRanked ? 'bg-neon-aqua/30' : 'bg-charcoal-outline/50'
}`}>
<Users className={`w-7 h-7 ${!isRanked ? 'text-neon-aqua' : 'text-gray-400'}`} />
</div>
<div>
<div className={`text-xl font-bold ${!isRanked ? 'text-white' : 'text-gray-300'}`}>
Unranked
</div>
<div className={`text-sm ${!isRanked ? 'text-neon-aqua' : 'text-gray-500'}`}>
Race with friends
</div>
</div>
</div>
{/* Radio indicator */}
<div className={`flex h-7 w-7 items-center justify-center rounded-full border-2 shrink-0 transition-colors ${
!isRanked ? 'border-neon-aqua bg-neon-aqua' : 'border-gray-500'
}`}>
{!isRanked && <Check className="w-4 h-4 text-deep-graphite" />}
</div>
</div>
{/* Emotional tagline */}
<p className={`text-sm ${!isRanked ? 'text-gray-300' : 'text-gray-500'}`}>
Pure racing fun. No pressure, no rankings — just you and your crew hitting the track.
</p>
{/* Features */}
<div className="space-y-2.5 py-2">
<div className="flex items-center gap-2 text-sm text-gray-400">
<Check className={`w-4 h-4 ${!isRanked ? 'text-neon-aqua' : 'text-gray-500'}`} />
<span>Private, invite-only access</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<Check className={`w-4 h-4 ${!isRanked ? 'text-neon-aqua' : 'text-gray-500'}`} />
<span>Zero impact on your rating</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<Check className={`w-4 h-4 ${!isRanked ? 'text-neon-aqua' : 'text-gray-500'}`} />
<span>Perfect for practice & fun</span>
</div>
</div>
{/* Flexibility badge */}
<div className="flex items-center gap-2 mt-auto px-3 py-2 rounded-lg bg-neon-aqua/10 border border-neon-aqua/20 w-fit">
<Users className={`w-4 h-4 ${!isRanked ? 'text-neon-aqua' : 'text-gray-400'}`} />
<span className={`text-xs font-medium ${!isRanked ? 'text-neon-aqua' : 'text-gray-400'}`}>
Any size — even 2 friends
</span>
</div>
</button>
{/* Info button */}
<button
ref={unrankedInfoRef}
type="button"
onClick={() => setShowUnrankedFlyout(true)}
className="absolute top-3 right-3 flex h-7 w-7 items-center justify-center rounded-full text-gray-500 hover:text-neon-aqua hover:bg-neon-aqua/10 transition-colors"
>
<HelpCircle className="w-4 h-4" />
</button>
</div>
{/* Unranked Info Flyout */}
<InfoFlyout
isOpen={showUnrankedFlyout}
onClose={() => setShowUnrankedFlyout(false)}
title="Unranked Leagues"
anchorRef={unrankedInfoRef}
>
<div className="space-y-4">
<p className="text-xs text-gray-400">
Unranked leagues are casual, private series for racing with friends.
Results don't affect driver ratings, so you can practice and have fun
without pressure.
</p>
<div className="space-y-2">
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Perfect For</div>
<ul className="space-y-1.5">
<li className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
<span>Private racing with friends</span>
</li>
<li className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
<span>Practice and training sessions</span>
</li>
<li className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
<span>Small groups (2+ drivers)</span>
</li>
</ul>
</div>
<div className="space-y-2">
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Features</div>
<ul className="space-y-1.5">
<li className="flex items-start gap-2 text-xs text-gray-400">
<Users className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
<span>Invite-only membership</span>
</li>
<li className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
<span>Full stats and standings (internal only)</span>
</li>
</ul>
</div>
</div>
</InfoFlyout>
</div>
{errors?.visibility && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-warning-amber/10 border border-warning-amber/20">
<HelpCircle className="w-4 h-4 text-warning-amber shrink-0" />
<p className="text-xs text-warning-amber">{errors.visibility}</p>
</div>
)}
{/* Contextual info based on selection */}
<div className={`rounded-xl p-5 border transition-all duration-300 ${
isRanked
? 'bg-primary-blue/5 border-primary-blue/20'
: 'bg-neon-aqua/5 border-neon-aqua/20'
}`}>
<div className="flex items-start gap-3">
{isRanked ? (
<>
<Trophy className="w-5 h-5 text-primary-blue shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-white mb-1">Ready to compete</p>
<p className="text-xs text-gray-400">
Your league will be visible to all GridPilot drivers. Results will affect driver ratings
and contribute to the global leaderboards. Make sure you have at least {MIN_RANKED_DRIVERS} drivers
to ensure competitive integrity.
</p>
</div>
</>
) : (
<>
<Users className="w-5 h-5 text-neon-aqua shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-white mb-1">Private racing awaits</p>
<p className="text-xs text-gray-400">
Your league will be invite-only. Perfect for racing with friends, practice sessions,
or any time you want to have fun without affecting your official ratings.
</p>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,126 +1,185 @@
'use client';
import Image from 'next/image';
import Card from '../ui/Card';
import {
Users,
Trophy,
Award,
Crown,
Star,
TrendingUp,
Shield,
ChevronRight,
UserPlus,
Zap,
Clock,
} from 'lucide-react';
import { getImageService } from '@/lib/di-container';
interface TeamCardProps {
id: string;
name: string;
description?: string;
logo?: string;
memberCount: number;
leagues: string[];
rating?: number | null;
totalWins?: number;
totalRaces?: number;
performanceLevel?: 'beginner' | 'intermediate' | 'advanced' | 'pro';
isRecruiting?: boolean;
specialization?: 'endurance' | 'sprint' | 'mixed';
leagues?: string[];
onClick?: () => void;
}
function getPerformanceBadge(level?: string) {
switch (level) {
case 'pro':
return { icon: Crown, label: 'Pro', color: 'text-yellow-400', bgColor: 'bg-yellow-500/20 border-yellow-500/30' };
case 'advanced':
return { icon: Star, label: 'Advanced', color: 'text-purple-400', bgColor: 'bg-purple-500/20 border-purple-500/30' };
case 'intermediate':
return { icon: TrendingUp, label: 'Intermediate', color: 'text-primary-blue', bgColor: 'bg-primary-blue/20 border-primary-blue/30' };
case 'beginner':
return { icon: Shield, label: 'Beginner', color: 'text-green-400', bgColor: 'bg-green-500/20 border-green-500/30' };
default:
return null;
}
}
function getSpecializationBadge(specialization?: string) {
switch (specialization) {
case 'endurance':
return { icon: Clock, label: 'Endurance', color: 'text-orange-400' };
case 'sprint':
return { icon: Zap, label: 'Sprint', color: 'text-neon-aqua' };
default:
return null;
}
}
export default function TeamCard({
id,
name,
description,
logo,
memberCount,
leagues,
rating,
totalWins,
totalRaces,
performanceLevel,
isRecruiting,
specialization,
onClick,
}: TeamCardProps) {
const performanceBadgeColors = {
beginner: 'bg-green-500/20 text-green-400',
intermediate: 'bg-blue-500/20 text-blue-400',
advanced: 'bg-purple-500/20 text-purple-400',
pro: 'bg-red-500/20 text-red-400',
};
const imageService = getImageService();
const logoUrl = logo || imageService.getTeamLogo(id);
const performanceBadge = getPerformanceBadge(performanceLevel);
const specializationBadge = getSpecializationBadge(specialization);
return (
<div
className="cursor-pointer hover:scale-[1.03] transition-transform duration-150"
className="group relative cursor-pointer h-full"
onClick={onClick}
>
<Card>
<div className="space-y-4">
{/* Card Container */}
<div className="relative h-full rounded-xl bg-iron-gray border border-charcoal-outline overflow-hidden transition-all duration-200 hover:border-purple-500/50 hover:shadow-[0_0_30px_rgba(168,85,247,0.15)] hover:bg-iron-gray/80 flex flex-col">
{/* Header with Logo */}
<div className="relative p-4 pb-0">
<div className="flex items-start gap-4">
<div className="w-16 h-16 bg-charcoal-outline rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">
{/* Logo */}
<div className="w-14 h-14 rounded-xl bg-charcoal-outline flex items-center justify-center flex-shrink-0 overflow-hidden border border-charcoal-outline">
<Image
src={logo || getImageService().getTeamLogo(id)}
src={logoUrl}
alt={name}
width={64}
height={64}
width={56}
height={56}
className="w-full h-full object-cover"
/>
</div>
{/* Title & Badges */}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-white truncate">
{name}
</h3>
<p className="text-sm text-gray-400">
{memberCount} {memberCount === 1 ? 'member' : 'members'}
</p>
{typeof rating === 'number' && (
<p className="text-xs text-primary-blue mt-1">
Team rating: <span className="font-semibold">{Math.round(rating)}</span>
</p>
<div className="flex items-start justify-between gap-2">
<h3 className="text-base font-semibold text-white truncate group-hover:text-purple-400 transition-colors">
{name}
</h3>
{isRecruiting && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-performance-green/20 text-performance-green border border-performance-green/30 whitespace-nowrap">
<UserPlus className="w-3 h-3" />
Recruiting
</span>
)}
</div>
{/* Performance Level */}
{performanceBadge && (
<div className="mt-1.5 flex items-center gap-2">
<span className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border ${performanceBadge.bgColor}`}>
<performanceBadge.icon className={`w-3 h-3 ${performanceBadge.color}`} />
<span className={performanceBadge.color}>{performanceBadge.label}</span>
</span>
{specializationBadge && (
<span className="flex items-center gap-1 text-[10px] text-gray-500">
<specializationBadge.icon className={`w-3 h-3 ${specializationBadge.color}`} />
{specializationBadge.label}
</span>
)}
</div>
)}
</div>
</div>
</div>
{performanceLevel && (
<div>
<span
className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${
performanceBadgeColors[performanceLevel]
}`}
>
{performanceLevel.charAt(0).toUpperCase() + performanceLevel.slice(1)}
</span>
</div>
)}
{/* Content */}
<div className="p-4 flex flex-col flex-1">
{/* Description */}
<p className="text-xs text-gray-500 line-clamp-2 mb-4 h-8">
{description || 'No description available'}
</p>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-sm text-gray-400">Rating</div>
<div className="text-lg font-semibold text-primary-blue">
{typeof rating === 'number' ? Math.round(rating) : '—'}
{/* Stats Grid */}
<div className="grid grid-cols-3 gap-2 mb-4">
<div className="text-center p-2 rounded-lg bg-charcoal-outline/30">
<div className="text-[10px] text-gray-500 mb-0.5">Rating</div>
<div className="text-sm font-semibold text-primary-blue">
{typeof rating === 'number' ? Math.round(rating).toLocaleString() : '—'}
</div>
</div>
<div>
<div className="text-sm text-gray-400">Wins</div>
<div className="text-lg font-semibold text-green-400">
<div className="text-center p-2 rounded-lg bg-charcoal-outline/30">
<div className="text-[10px] text-gray-500 mb-0.5">Wins</div>
<div className="text-sm font-semibold text-performance-green">
{totalWins ?? 0}
</div>
</div>
<div>
<div className="text-sm text-gray-400">Races</div>
<div className="text-lg font-semibold text-white">
<div className="text-center p-2 rounded-lg bg-charcoal-outline/30">
<div className="text-[10px] text-gray-500 mb-0.5">Races</div>
<div className="text-sm font-semibold text-white">
{totalRaces ?? 0}
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-gray-400">Active in:</p>
<div className="flex flex-wrap gap-2">
{leagues.slice(0, 3).map((league, idx) => (
<span
key={idx}
className="inline-block px-2 py-1 bg-charcoal-outline text-gray-300 rounded text-xs"
>
{league}
</span>
))}
{leagues.length > 3 && (
<span className="inline-block px-2 py-1 bg-charcoal-outline text-gray-400 rounded text-xs">
+{leagues.length - 3} more
</span>
)}
{/* Spacer */}
<div className="flex-1" />
{/* Footer */}
<div className="flex items-center justify-between pt-3 border-t border-charcoal-outline/50 mt-auto">
<div className="flex items-center gap-2 text-[10px] text-gray-500">
<Users className="w-3 h-3" />
<span>
{memberCount} {memberCount === 1 ? 'member' : 'members'}
</span>
</div>
{/* View Arrow */}
<div className="flex items-center gap-1 text-[10px] text-gray-500 group-hover:text-purple-400 transition-colors">
<span>View</span>
<ChevronRight className="w-3 h-3 transition-transform group-hover:translate-x-0.5" />
</div>
</div>
</div>
</Card>
</div>
</div>
);
}

View File

@@ -9,12 +9,16 @@ import {
getDriverRepository,
getCreateLeagueWithSeasonAndScoringUseCase,
} from '@/lib/di-container';
import { LeagueName } from '@gridpilot/racing/domain/value-objects/LeagueName';
import { LeagueDescription } from '@gridpilot/racing/domain/value-objects/LeagueDescription';
import { GameConstraints } from '@gridpilot/racing/domain/value-objects/GameConstraints';
export type WizardStep = 1 | 2 | 3 | 4 | 5;
export type WizardStep = 1 | 2 | 3 | 4 | 5 | 6;
export interface WizardErrors {
basics?: {
name?: string;
description?: string;
visibility?: string;
};
structure?: {
@@ -43,42 +47,86 @@ export function validateLeagueWizardStep(
): WizardErrors {
const errors: WizardErrors = {};
// Step 1: Basics (name, description, game)
if (step === 1) {
const basicsErrors: NonNullable<WizardErrors['basics']> = {};
if (!form.basics.name.trim()) {
basicsErrors.name = 'Name is required';
// Use LeagueName value object for validation
const nameValidation = LeagueName.validate(form.basics.name);
if (!nameValidation.valid) {
basicsErrors.name = nameValidation.error;
}
if (!form.basics.visibility) {
basicsErrors.visibility = 'Visibility is required';
// Use LeagueDescription value object for validation
const descValidation = LeagueDescription.validate(form.basics.description ?? '');
if (!descValidation.valid) {
basicsErrors.description = descValidation.error;
}
if (Object.keys(basicsErrors).length > 0) {
errors.basics = basicsErrors;
}
}
// Step 2: Visibility (ranked/unranked)
if (step === 2) {
const basicsErrors: NonNullable<WizardErrors['basics']> = {};
if (!form.basics.visibility) {
basicsErrors.visibility = 'Visibility is required';
}
if (Object.keys(basicsErrors).length > 0) {
errors.basics = basicsErrors;
}
}
// Step 3: Structure (solo vs teams)
if (step === 3) {
const structureErrors: NonNullable<WizardErrors['structure']> = {};
const gameConstraints = GameConstraints.forGame(form.basics.gameId);
if (form.structure.mode === 'solo') {
if (!form.structure.maxDrivers || form.structure.maxDrivers <= 0) {
structureErrors.maxDrivers =
'Max drivers must be greater than 0 for solo leagues';
} else {
// Validate against game constraints
const driverValidation = gameConstraints.validateDriverCount(form.structure.maxDrivers);
if (!driverValidation.valid) {
structureErrors.maxDrivers = driverValidation.error;
}
}
} else if (form.structure.mode === 'fixedTeams') {
if (!form.structure.maxTeams || form.structure.maxTeams <= 0) {
structureErrors.maxTeams =
'Max teams must be greater than 0 for team leagues';
} else {
// Validate against game constraints
const teamValidation = gameConstraints.validateTeamCount(form.structure.maxTeams);
if (!teamValidation.valid) {
structureErrors.maxTeams = teamValidation.error;
}
}
if (!form.structure.driversPerTeam || form.structure.driversPerTeam <= 0) {
structureErrors.driversPerTeam =
'Drivers per team must be greater than 0';
}
// Validate total driver count
if (form.structure.maxDrivers) {
const driverValidation = gameConstraints.validateDriverCount(form.structure.maxDrivers);
if (!driverValidation.valid) {
structureErrors.maxDrivers = driverValidation.error;
}
}
}
if (Object.keys(structureErrors).length > 0) {
errors.structure = structureErrors;
}
}
if (step === 3) {
// Step 4: Schedule (timings)
if (step === 4) {
const timingsErrors: NonNullable<WizardErrors['timings']> = {};
if (!form.timings.qualifyingMinutes || form.timings.qualifyingMinutes <= 0) {
timingsErrors.qualifyingMinutes =
@@ -93,7 +141,8 @@ export function validateLeagueWizardStep(
}
}
if (step === 4) {
// Step 5: Scoring
if (step === 5) {
const scoringErrors: NonNullable<WizardErrors['scoring']> = {};
if (!form.scoring.patternId && !form.scoring.customScoringEnabled) {
scoringErrors.patternId =
@@ -104,6 +153,8 @@ export function validateLeagueWizardStep(
}
}
// Step 6: Review - no validation needed, it's just review
return errors;
}
@@ -137,6 +188,7 @@ export function validateAllLeagueWizardSteps(
merge(validateLeagueWizardStep(form, 2));
merge(validateLeagueWizardStep(form, 3));
merge(validateLeagueWizardStep(form, 4));
merge(validateLeagueWizardStep(form, 5));
return aggregate;
}