Files
gridpilot.gg/apps/website/components/leagues/LeagueStructureSection.tsx
2025-12-16 11:52:26 +01:00

702 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { 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 type { LeagueConfigFormModel } from '@core/racing/application';
import { GameConstraints } from '@core/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;
onChange?: (form: LeagueConfigFormModel) => void;
readOnly?: boolean;
}
export function LeagueStructureSection({
form,
onChange,
readOnly,
}: LeagueStructureSectionProps) {
const disabled = readOnly || !onChange;
const structure = form.structure;
const updateStructure = (
patch: Partial<LeagueConfigFormModel['structure']>,
) => {
if (!onChange) return;
const nextStructure = {
...structure,
...patch,
};
let nextForm: LeagueConfigFormModel = {
...form,
structure: nextStructure,
};
if (nextStructure.mode === 'fixedTeams') {
const maxTeams =
typeof nextStructure.maxTeams === 'number' &&
nextStructure.maxTeams > 0
? nextStructure.maxTeams
: 1;
const driversPerTeam =
typeof nextStructure.driversPerTeam === 'number' &&
nextStructure.driversPerTeam > 0
? nextStructure.driversPerTeam
: 1;
const maxDrivers = maxTeams * driversPerTeam;
nextForm = {
...nextForm,
structure: {
...nextStructure,
maxTeams,
driversPerTeam,
maxDrivers,
},
};
}
if (nextStructure.mode === 'solo') {
const { maxTeams, driversPerTeam, ...restStructure } = nextStructure;
nextForm = {
...nextForm,
structure: restStructure,
};
}
onChange(nextForm);
};
const handleModeChange = (mode: 'solo' | 'fixedTeams') => {
if (mode === structure.mode) return;
if (mode === 'solo') {
updateStructure({
mode: 'solo',
maxDrivers: structure.maxDrivers || 24,
});
} else {
const maxTeams = structure.maxTeams ?? 12;
const driversPerTeam = structure.driversPerTeam ?? 2;
updateStructure({
mode: 'fixedTeams',
maxTeams,
driversPerTeam,
maxDrivers: maxTeams * driversPerTeam,
});
}
};
const handleMaxDriversChange = (value: string) => {
const parsed = parseInt(value, 10);
updateStructure({
maxDrivers: Number.isNaN(parsed) ? 0 : parsed,
});
};
const handleMaxTeamsChange = (value: string) => {
const parsed = parseInt(value, 10);
const maxTeams = Number.isNaN(parsed) ? 0 : parsed;
const driversPerTeam = structure.driversPerTeam ?? 2;
updateStructure({
maxTeams,
driversPerTeam,
maxDrivers:
maxTeams > 0 && driversPerTeam > 0
? maxTeams * driversPerTeam
: structure.maxDrivers,
});
};
const handleDriversPerTeamChange = (value: string) => {
const parsed = parseInt(value, 10);
const driversPerTeam = Number.isNaN(parsed) ? 0 : parsed;
const maxTeams = structure.maxTeams ?? 12;
updateStructure({
driversPerTeam,
maxTeams,
maxDrivers:
maxTeams > 0 && driversPerTeam > 0
? maxTeams * driversPerTeam
: structure.maxDrivers,
});
};
// 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-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>
{/* 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>
{/* 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>
{/* 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">
Maximum drivers
</label>
<Input
type="number"
value={structure.maxDrivers ?? gameConstraints.defaultMaxDrivers}
onChange={(e) => handleMaxDriversChange(e.target.value)}
disabled={disabled}
min={gameConstraints.minDrivers}
max={gameConstraints.maxDrivers}
className="w-40"
/>
<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-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'
}`}
>
Full Grid (30)
</button>
)}
{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>
)}
{/* 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={() => {
handleMaxTeamsChange('10');
handleDriversPerTeamChange('2');
}}
disabled={disabled}
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 × 2 (20 grid)
</button>
<button
type="button"
onClick={() => {
handleMaxTeamsChange('12');
handleDriversPerTeamChange('2');
}}
disabled={disabled}
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 × 2 (24 grid)
</button>
<button
type="button"
onClick={() => {
handleMaxTeamsChange('8');
handleDriversPerTeamChange('3');
}}
disabled={disabled}
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 × 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>
{/* 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">
Teams
</label>
<Input
type="number"
value={structure.maxTeams ?? 12}
onChange={(e) => handleMaxTeamsChange(e.target.value)}
disabled={disabled}
min={1}
max={32}
className="w-full"
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-300">
Drivers / team
</label>
<Input
type="number"
value={structure.driversPerTeam ?? 2}
onChange={(e) => handleDriversPerTeamChange(e.target.value)}
disabled={disabled}
min={1}
max={6}
className="w-full"
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-300">
Total grid
</label>
<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>
</div>
</div>
</div>
)}
</div>
</div>
);
}