Files
gridpilot.gg/apps/website/components/leagues/LeagueVisibilitySection.tsx
2026-01-18 23:24:30 +01:00

608 lines
23 KiB
TypeScript

'use client';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Check, HelpCircle, Trophy, Users, X } from 'lucide-react';
import type * as React from 'react';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
// 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(
<Stack
ref={flyoutRef}
position="fixed"
zIndex={50}
w="340px"
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
rounded="xl"
shadow="2xl"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ top: position.top, left: position.left, maxHeight: '80vh', overflowY: 'auto' }}
>
<Stack
display="flex"
alignItems="center"
justifyContent="between"
p={4}
borderBottom
borderColor="border-charcoal-outline/50"
position="sticky"
top="0"
bg="bg-iron-gray"
zIndex={10}
>
<Stack direction="row" align="center" gap={2}>
<Icon icon={HelpCircle} size={4} color="text-primary-blue" />
<Text size="sm" weight="semibold" color="text-white">{title}</Text>
</Stack>
<Stack
as="button"
type="button"
onClick={onClose}
display="flex"
h="6"
w="6"
alignItems="center"
justifyContent="center"
rounded="md"
transition
hoverBg="bg-charcoal-outline"
>
<Icon icon={X} size={4} color="text-gray-400" />
</Stack>
</Stack>
<Stack p={4}>
{children}
</Stack>
</Stack>,
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 (
<Stack gap={8}>
{/* Emotional header for the step */}
<Stack textAlign="center" pb={2}>
<Heading level={3} mb={2}>
Choose your league&apos;s destiny
</Heading>
<Text size="sm" color="text-gray-400" maxWidth="lg" mx="auto" block>
Will you compete for glory on the global leaderboards, or race with friends in a private series?
</Text>
</Stack>
{/* League Type Selection */}
<Stack display="grid" responsiveGridCols={{ base: 1, md: 2 }} gap={6}>
{/* Ranked (Public) Option */}
<Stack position="relative">
<Stack
as="button"
type="button"
disabled={disabled}
onClick={() => handleVisibilityChange('public')}
display="flex"
flexDirection="col"
gap={4}
p={6}
textAlign="left"
rounded="xl"
border
borderColor={isRanked ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={isRanked ? 'bg-primary-blue/15' : 'bg-iron-gray/30'}
w="full"
position="relative"
transition
shadow={isRanked ? '0_0_30px_rgba(25,140,255,0.25)' : undefined}
hoverBorderColor={!isRanked && !disabled ? 'border-gray-500' : undefined}
hoverBg={!isRanked && !disabled ? 'bg-iron-gray/50' : undefined}
opacity={disabled ? 0.6 : 1}
cursor={disabled ? 'not-allowed' : 'pointer'}
group
>
{/* Header */}
<Stack display="flex" alignItems="start" justifyContent="between">
<Stack direction="row" align="center" gap={3}>
<Stack
display="flex"
h="14"
w="14"
alignItems="center"
justifyContent="center"
rounded="xl"
bg={isRanked ? 'bg-primary-blue/30' : 'bg-charcoal-outline/50'}
>
<Icon icon={Trophy} size={7} color={isRanked ? 'text-primary-blue' : 'text-gray-400'} />
</Stack>
<Stack>
<Text weight="bold" size="xl" color={isRanked ? 'text-white' : 'text-gray-300'} block>
Ranked
</Text>
<Text size="sm" color={isRanked ? 'text-primary-blue' : 'text-gray-500'} block>
Compete for glory
</Text>
</Stack>
</Stack>
{/* Radio indicator */}
<Stack
display="flex"
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="full"
border
borderColor={isRanked ? 'border-primary-blue' : 'border-gray-500'}
bg={isRanked ? 'bg-primary-blue' : ''}
flexShrink={0}
transition
>
{isRanked && <Icon icon={Check} size={4} color="text-white" />}
</Stack>
</Stack>
{/* Emotional tagline */}
<Text size="sm" color={isRanked ? 'text-gray-300' : 'text-gray-500'} block>
Your results matter. Build your reputation in the global standings and climb the ranks.
</Text>
{/* Features */}
<Stack gap={2.5} py={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Check} size={4} color="text-performance-green" />
<Text size="sm" color="text-gray-400">Discoverable by all drivers</Text>
</Stack>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Check} size={4} color="text-performance-green" />
<Text size="sm" color="text-gray-400">Affects driver ratings & rankings</Text>
</Stack>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Check} size={4} color="text-performance-green" />
<Text size="sm" color="text-gray-400">Featured on leaderboards</Text>
</Stack>
</Stack>
{/* Requirement badge */}
<Stack display="flex" alignItems="center" gap={2} mt="auto" px={3} py={2} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/20" w="fit">
<Icon icon={Users} size={4} color="text-warning-amber" />
<Text size="xs" color="text-warning-amber" weight="medium">
Requires {MIN_RANKED_DRIVERS}+ drivers for competitive integrity
</Text>
</Stack>
</Stack>
{/* Info button */}
<Stack
as="button"
ref={rankedInfoRef}
type="button"
onClick={() => setShowRankedFlyout(true)}
position="absolute"
top="3"
right="3"
display="flex"
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="full"
transition
color="text-gray-500"
hoverTextColor="text-primary-blue"
hoverBg="bg-primary-blue/10"
>
<Icon icon={HelpCircle} size={4} />
</Stack>
</Stack>
{/* Ranked Info Flyout */}
<InfoFlyout
isOpen={showRankedFlyout}
onClose={() => setShowRankedFlyout(false)}
title="Ranked Leagues"
anchorRef={rankedInfoRef}
>
<Stack gap={4}>
<Text size="xs" color="text-gray-400" block>
Ranked leagues are competitive series where results matter. Your performance
affects your driver rating and contributes to global leaderboards.
</Text>
<Stack gap={2}>
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
block
>
Requirements
</Text>
<Stack gap={1.5}>
<Stack display="flex" alignItems="start" gap={2}>
<Icon icon={Users} size={3.5} color="text-warning-amber" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">
<Text weight="bold" color="text-white">Minimum {MIN_RANKED_DRIVERS} drivers</Text> for competitive integrity
</Text>
</Stack>
<Stack display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3.5} color="text-performance-green" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">Anyone can discover and join your league</Text>
</Stack>
</Stack>
</Stack>
<Stack gap={2}>
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
block
>
Benefits
</Text>
<Stack gap={1.5}>
<Stack display="flex" alignItems="start" gap={2}>
<Icon icon={Trophy} size={3.5} color="text-primary-blue" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">Results affect driver ratings and rankings</Text>
</Stack>
<Stack display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3.5} color="text-performance-green" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">Featured in league discovery</Text>
</Stack>
</Stack>
</Stack>
</Stack>
</InfoFlyout>
{/* Unranked (Private) Option */}
<Stack position="relative">
<Stack
as="button"
type="button"
disabled={disabled}
onClick={() => handleVisibilityChange('private')}
display="flex"
flexDirection="col"
gap={4}
p={6}
textAlign="left"
rounded="xl"
border
borderColor={!isRanked ? 'border-neon-aqua' : 'border-charcoal-outline'}
bg={!isRanked ? 'bg-neon-aqua/15' : 'bg-iron-gray/30'}
w="full"
position="relative"
transition
shadow={!isRanked ? '0_0_30px_rgba(67,201,230,0.2)' : undefined}
hoverBorderColor={isRanked && !disabled ? 'border-gray-500' : undefined}
hoverBg={isRanked && !disabled ? 'bg-iron-gray/50' : undefined}
opacity={disabled ? 0.6 : 1}
cursor={disabled ? 'not-allowed' : 'pointer'}
group
>
{/* Header */}
<Stack display="flex" alignItems="start" justifyContent="between">
<Stack direction="row" align="center" gap={3}>
<Stack
display="flex"
h="14"
w="14"
alignItems="center"
justifyContent="center"
rounded="xl"
bg={!isRanked ? 'bg-neon-aqua/30' : 'bg-charcoal-outline/50'}
>
<Icon icon={Users} size={7} color={!isRanked ? 'text-neon-aqua' : 'text-gray-400'} />
</Stack>
<Stack>
<Text weight="bold" size="xl" color={!isRanked ? 'text-white' : 'text-gray-300'} block>
Unranked
</Text>
<Text size="sm" color={!isRanked ? 'text-neon-aqua' : 'text-gray-500'} block>
Race with friends
</Text>
</Stack>
</Stack>
{/* Radio indicator */}
<Stack
display="flex"
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="full"
border
borderColor={!isRanked ? 'border-neon-aqua' : 'border-gray-500'}
bg={!isRanked ? 'bg-neon-aqua' : ''}
flexShrink={0}
transition
>
{!isRanked && <Icon icon={Check} size={4} color="text-deep-graphite" />}
</Stack>
</Stack>
{/* Emotional tagline */}
<Text size="sm" color={!isRanked ? 'text-gray-300' : 'text-gray-500'} block>
Pure racing fun. No pressure, no rankings just you and your crew hitting the track.
</Text>
{/* Features */}
<Stack gap={2.5} py={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Check} size={4} color={!isRanked ? 'text-neon-aqua' : 'text-gray-400'} />
<Text size="sm" color="text-gray-400">Private, invite-only access</Text>
</Stack>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Check} size={4} color={!isRanked ? 'text-neon-aqua' : 'text-gray-400'} />
<Text size="sm" color="text-gray-400">Zero impact on your rating</Text>
</Stack>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Check} size={4} color={!isRanked ? 'text-neon-aqua' : 'text-gray-400'} />
<Text size="sm" color="text-gray-400">Perfect for practice & fun</Text>
</Stack>
</Stack>
{/* Flexibility badge */}
<Stack display="flex" alignItems="center" gap={2} mt="auto" px={3} py={2} rounded="lg" bg="bg-neon-aqua/10" border borderColor="border-neon-aqua/20" w="fit">
<Icon icon={Users} size={4} color={!isRanked ? 'text-neon-aqua' : 'text-gray-400'} />
<Text size="xs" color={!isRanked ? 'text-neon-aqua' : 'text-gray-400'} weight="medium">
Any size even 2 friends
</Text>
</Stack>
</Stack>
{/* Info button */}
<Stack
as="button"
ref={unrankedInfoRef}
type="button"
onClick={() => setShowUnrankedFlyout(true)}
position="absolute"
top="3"
right="3"
display="flex"
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="full"
transition
color="text-gray-500"
hoverTextColor="text-neon-aqua"
hoverBg="bg-neon-aqua/10"
>
<Icon icon={HelpCircle} size={4} />
</Stack>
</Stack>
{/* Unranked Info Flyout */}
<InfoFlyout
isOpen={showUnrankedFlyout}
onClose={() => setShowUnrankedFlyout(false)}
title="Unranked Leagues"
anchorRef={unrankedInfoRef}
>
<Stack gap={4}>
<Text size="xs" color="text-gray-400" block>
Unranked leagues are casual, private series for racing with friends.
Results don&apos;t affect driver ratings, so you can practice and have fun
without pressure.
</Text>
<Stack gap={2}>
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
block
>
Perfect For
</Text>
<Stack gap={1.5}>
<Stack display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3.5} color="text-neon-aqua" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">Private racing with friends</Text>
</Stack>
<Stack display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3.5} color="text-neon-aqua" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">Practice and training sessions</Text>
</Stack>
<Stack display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3.5} color="text-neon-aqua" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">Small groups (2+ drivers)</Text>
</Stack>
</Stack>
</Stack>
<Stack gap={2}>
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
block
>
Features
</Text>
<Stack gap={1.5}>
<Stack display="flex" alignItems="start" gap={2}>
<Icon icon={Users} size={3.5} color="text-neon-aqua" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">Invite-only membership</Text>
</Stack>
<Stack display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3.5} color="text-neon-aqua" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">Full stats and standings (internal only)</Text>
</Stack>
</Stack>
</Stack>
</Stack>
</InfoFlyout>
</Stack>
{errors?.visibility && (
<Stack display="flex" alignItems="center" gap={2} p={3} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/20">
<Icon icon={HelpCircle} size={4} color="text-warning-amber" flexShrink={0} />
<Text size="xs" color="text-warning-amber">{errors.visibility}</Text>
</Stack>
)}
{/* Contextual info based on selection */}
<Stack
rounded="xl"
p={5}
border
transition
bg={isRanked ? 'bg-primary-blue/5' : 'bg-neon-aqua/5'}
borderColor={isRanked ? 'border-primary-blue/20' : 'border-neon-aqua/20'}
>
<Stack display="flex" alignItems="start" gap={3}>
{isRanked ? (
<>
<Icon icon={Trophy} size={5} color="text-primary-blue" flexShrink={0} mt={0.5} />
<Stack>
<Text size="sm" weight="medium" color="text-white" block mb={1}>Ready to compete</Text>
<Text size="xs" color="text-gray-400" block>
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.
</Text>
</Stack>
</>
) : (
<>
<Icon icon={Users} size={5} color="text-neon-aqua" flexShrink={0} mt={0.5} />
<Stack>
<Text size="sm" weight="medium" color="text-white" block mb={1}>Private racing awaits</Text>
<Text size="xs" color="text-gray-400" block>
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.
</Text>
</Stack>
</>
)}
</Stack>
</Stack>
</Stack>
);
}