461 lines
19 KiB
TypeScript
461 lines
19 KiB
TypeScript
'use client';
|
|
|
|
import { Trophy, Users, Check, HelpCircle, X } from 'lucide-react';
|
|
import { useState, useRef, useEffect } from 'react';
|
|
import type * as React from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
|
|
|
// 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>;
|
|
}
|
|
|
|
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 === 'public';
|
|
|
|
// Auto-update minDrivers when switching to ranked
|
|
const handleVisibilityChange = (visibility: 'public' | 'private' | 'unlisted') => {
|
|
if (!onChange) return;
|
|
|
|
// If switching to public and current maxDrivers is below minimum, update it
|
|
if (visibility === 'public' && (form.structure?.maxDrivers ?? 0) < 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('public')}
|
|
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('private')}
|
|
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>
|
|
);
|
|
} |