website refactor

This commit is contained in:
2026-01-15 17:12:24 +01:00
parent c3b308e960
commit f035cfe7ce
468 changed files with 24378 additions and 17324 deletions

View File

@@ -1,9 +1,14 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { TrendingDown, Check, HelpCircle, X, Zap } from 'lucide-react';
import { createPortal } from 'react-dom';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Check, HelpCircle, TrendingDown, X, Zap } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
// ============================================================================
// INFO FLYOUT (duplicated for self-contained component)
@@ -78,42 +83,79 @@ function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutP
if (!isOpen) return null;
return createPortal(
<div
<Box
ref={flyoutRef}
className="fixed z-50 w-[380px] 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 }}
position="fixed"
zIndex={50}
w="380px"
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' }}
>
<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
<Box
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>
<Box
as="button"
type="button"
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-charcoal-outline transition-colors"
display="flex"
h="6"
w="6"
alignItems="center"
justifyContent="center"
rounded="md"
transition
hoverBg="bg-charcoal-outline"
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
<div className="p-4">
<Icon icon={X} size={4} color="text-gray-400" />
</Box>
</Box>
<Box p={4}>
{children}
</div>
</div>,
</Box>
</Box>,
document.body
);
}
function InfoButton({ onClick, buttonRef }: { onClick: () => void; buttonRef: React.Ref<HTMLButtonElement> }) {
return (
<button
<Box
as="button"
ref={buttonRef}
type="button"
onClick={onClick}
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"
display="flex"
h="5"
w="5"
alignItems="center"
justifyContent="center"
rounded="full"
transition
color="text-gray-500"
hoverTextColor="text-primary-blue"
hoverBg="bg-primary-blue/10"
>
<HelpCircle className="w-3.5 h-3.5" />
</button>
<Icon icon={HelpCircle} size={3.5} />
</Box>
);
}
@@ -132,36 +174,65 @@ function DropRulesMockup() {
const wouldBe = results.reduce((sum, r) => sum + r.pts, 0);
return (
<div className="bg-deep-graphite rounded-lg p-4">
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-charcoal-outline/50">
<span className="text-xs font-semibold text-white">Best 4 of 6 Results</span>
</div>
<div className="flex gap-1 mb-3">
<Box bg="bg-deep-graphite" rounded="lg" p={4}>
<Box display="flex" alignItems="center" gap={2} mb={3} pb={2} borderBottom borderColor="border-charcoal-outline/50" opacity={0.5}>
<Text size="xs" weight="semibold" color="text-white">Best 4 of 6 Results</Text>
</Box>
<Box display="flex" gap={1} mb={3}>
{results.map((r, i) => (
<div
<Box
key={i}
className={`flex-1 p-2 rounded-lg text-center border transition-all ${
r.dropped
? 'bg-charcoal-outline/20 border-dashed border-charcoal-outline/50 opacity-50'
: 'bg-performance-green/10 border-performance-green/30'
}`}
flexGrow={1}
p={2}
rounded="lg"
textAlign="center"
border
transition
bg={r.dropped ? 'bg-charcoal-outline/20' : 'bg-performance-green/10'}
borderColor={r.dropped ? 'border-charcoal-outline/50' : 'border-performance-green/30'}
opacity={r.dropped ? 0.5 : 1}
// eslint-disable-next-line gridpilot-rules/component-classification
className={r.dropped ? 'border-dashed' : ''}
>
<div className="text-[9px] text-gray-500">{r.round}</div>
<div className={`text-xs font-mono font-semibold ${r.dropped ? 'text-gray-500 line-through' : 'text-white'}`}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
color="text-gray-500"
block
>
{r.round}
</Text>
<Text font="mono" weight="semibold" size="xs" color={r.dropped ? 'text-gray-500' : 'text-white'}
// eslint-disable-next-line gridpilot-rules/component-classification
className={r.dropped ? 'line-through' : ''}
block
>
{r.pts}
</div>
</div>
</Text>
</Box>
))}
</div>
<div className="flex justify-between items-center text-xs">
<span className="text-gray-500">Total counted:</span>
<span className="font-mono font-semibold text-performance-green">{total} pts</span>
</div>
<div className="flex justify-between items-center text-[10px] text-gray-500 mt-1">
<span>Without drops:</span>
<span className="font-mono">{wouldBe} pts</span>
</div>
</div>
</Box>
<Box display="flex" justifyContent="between" alignItems="center">
<Text size="xs" color="text-gray-500">Total counted:</Text>
<Text font="mono" weight="semibold" color="text-performance-green" size="xs">{total} pts</Text>
</Box>
<Box display="flex" justifyContent="between" alignItems="center" mt={1}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
Without drops:
</Text>
<Text font="mono"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
{wouldBe} pts
</Text>
</Box>
</Box>
);
}
@@ -290,20 +361,20 @@ export function LeagueDropSection({
const needsN = dropPolicy.strategy !== 'none';
return (
<div className="space-y-4">
<Stack gap={4}>
{/* Section header */}
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary-blue/10">
<TrendingDown className="w-5 h-5 text-primary-blue" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-white">Drop Rules</h3>
<Box display="flex" alignItems="center" gap={3}>
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/10">
<Icon icon={TrendingDown} size={5} color="text-primary-blue" />
</Box>
<Box flexGrow={1}>
<Box display="flex" alignItems="center" gap={2}>
<Heading level={3}>Drop Rules</Heading>
<InfoButton buttonRef={dropInfoRef} onClick={() => setShowDropFlyout(true)} />
</div>
<p className="text-xs text-gray-500">Protect from bad races</p>
</div>
</div>
</Box>
<Text size="xs" color="text-gray-500">Protect from bad races</Text>
</Box>
</Box>
{/* Drop Rules Flyout */}
<InfoFlyout
@@ -312,180 +383,306 @@ export function LeagueDropSection({
title="Drop Rules Explained"
anchorRef={dropInfoRef}
>
<div className="space-y-4">
<p className="text-xs text-gray-400">
<Stack gap={4}>
<Text size="xs" color="text-gray-400" block>
Drop rules allow drivers to exclude their worst results from championship calculations.
This protects against mechanical failures, bad luck, or occasional poor performances.
</p>
</Text>
<div>
<div className="text-[10px] text-gray-500 uppercase tracking-wide mb-2">Visual Example</div>
<Box>
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
block
mb={2}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Visual Example
</Text>
<DropRulesMockup />
</div>
</Box>
<div className="space-y-2">
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Drop Strategies</div>
<div className="space-y-2">
<div className="flex items-start gap-2 p-2 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
<span className="text-base"></span>
<div>
<div className="text-[10px] font-medium text-white">All Count</div>
<div className="text-[9px] text-gray-500">Every race affects standings. Best for short seasons.</div>
</div>
</div>
<div className="flex items-start gap-2 p-2 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
<span className="text-base">🏆</span>
<div>
<div className="text-[10px] font-medium text-white">Best N Results</div>
<div className="text-[9px] text-gray-500">Only your top N races count. Extra races are optional.</div>
</div>
</div>
<div className="flex items-start gap-2 p-2 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
<span className="text-base">🗑</span>
<div>
<div className="text-[10px] font-medium text-white">Drop Worst N</div>
<div className="text-[9px] text-gray-500">Exclude your N worst results. Forgives bad days.</div>
</div>
</div>
</div>
</div>
<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
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Drop Strategies
</Text>
<Stack gap={2}>
<Box display="flex" alignItems="start" gap={2} p={2} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<Text size="base"></Text>
<Box>
<Text size="xs" weight="medium" color="text-white" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
All Count
</Text>
<Text size="xs" color="text-gray-500" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
>
Every race affects standings. Best for short seasons.
</Text>
</Box>
</Box>
<Box display="flex" alignItems="start" gap={2} p={2} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<Text size="base">🏆</Text>
<Box>
<Text size="xs" weight="medium" color="text-white" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Best N Results
</Text>
<Text size="xs" color="text-gray-500" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
>
Only your top N races count. Extra races are optional.
</Text>
</Box>
</Box>
<Box display="flex" alignItems="start" gap={2} p={2} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<Text size="base">🗑</Text>
<Box>
<Text size="xs" weight="medium" color="text-white" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Drop Worst N
</Text>
<Text size="xs" color="text-gray-500" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
>
Exclude your N worst results. Forgives bad days.
</Text>
</Box>
</Box>
</Stack>
</Stack>
<div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-3">
<div className="flex items-start gap-2">
<Zap className="w-3.5 h-3.5 text-primary-blue shrink-0 mt-0.5" />
<div className="text-[11px] text-gray-400">
<span className="font-medium text-primary-blue">Pro tip:</span> For an 8-round season,
"Best 6" or "Drop 2" are popular choices.
</div>
</div>
</div>
</div>
<Box rounded="lg" bg="bg-primary-blue/5" border borderColor="border-primary-blue/20" p={3}>
<Box display="flex" alignItems="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-primary-blue" flexShrink={0} mt={0.5} />
<Box>
<Text size="xs" color="text-gray-400"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
>
<Text weight="medium" color="text-primary-blue">Pro tip:</Text> For an 8-round season,
&quot;Best 6&quot; or &quot;Drop 2&quot; are popular choices.
</Text>
</Box>
</Box>
</Box>
</Stack>
</InfoFlyout>
{/* Strategy buttons + N stepper inline */}
<div className="flex flex-wrap items-center gap-2">
<Box display="flex" flexWrap="wrap" alignItems="center" gap={2}>
{DROP_OPTIONS.map((option) => {
const isSelected = dropPolicy.strategy === option.value;
const ruleInfo = DROP_RULE_INFO[option.value];
return (
<div key={option.value} className="relative flex items-center">
<button
<Box key={option.value} display="flex" alignItems="center" position="relative">
<Box
as="button"
type="button"
disabled={disabled}
onClick={() => handleStrategyChange(option.value)}
className={`
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'
}
${disabled ? 'cursor-default opacity-60' : 'cursor-pointer'}
`}
display="flex"
alignItems="center"
gap={2}
px={3}
py={2}
rounded="lg"
border
borderWidth="2px"
transition
borderColor={isSelected ? 'border-primary-blue' : 'border-charcoal-outline/40'}
bg={isSelected ? 'bg-primary-blue/10' : 'bg-iron-gray/20'}
hoverBorderColor={!isSelected && !disabled ? 'border-charcoal-outline' : undefined}
hoverBg={!isSelected && !disabled ? 'bg-iron-gray/30' : undefined}
cursor={disabled ? 'default' : 'pointer'}
opacity={disabled ? 0.6 : 1}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ borderRightWidth: 0, borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
>
{/* Radio indicator */}
<div className={`
flex h-4 w-4 items-center justify-center rounded-full border-2 shrink-0 transition-colors
${isSelected ? 'border-primary-blue bg-primary-blue' : 'border-gray-500'}
`}>
{isSelected && <Check className="w-2.5 h-2.5 text-white" />}
</div>
<Box
display="flex"
h="4"
w="4"
alignItems="center"
justifyContent="center"
rounded="full"
border
borderColor={isSelected ? 'border-primary-blue' : 'border-gray-500'}
bg={isSelected ? 'bg-primary-blue' : ''}
flexShrink={0}
transition
>
{isSelected && <Icon icon={Check} size={2.5} color="text-white" />}
</Box>
<span className="text-sm">{option.emoji}</span>
<span className={`text-sm font-medium ${isSelected ? 'text-white' : 'text-gray-400'}`}>
<Text size="sm">{option.emoji}</Text>
<Text size="sm" weight="medium" color={isSelected ? 'text-white' : 'text-gray-400'}>
{option.label}
</span>
</button>
</Text>
</Box>
{/* Info button - separate from main button */}
<button
ref={(el) => { dropRuleRefs.current[option.value] = el; }}
<Box
as="button"
ref={(el: HTMLButtonElement | null) => { dropRuleRefs.current[option.value] = el; }}
type="button"
onClick={(e) => {
onClick={(e: React.MouseEvent) => {
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'}
`}
display="flex"
alignItems="center"
justifyContent="center"
px={2}
py={2}
rounded="lg"
border
borderWidth="2px"
transition
borderColor={isSelected ? 'border-primary-blue' : 'border-charcoal-outline/40'}
bg={isSelected ? 'bg-primary-blue/10' : 'bg-iron-gray/20'}
hoverBorderColor={!isSelected && !disabled ? 'border-charcoal-outline' : undefined}
hoverBg={!isSelected && !disabled ? 'bg-iron-gray/30' : undefined}
cursor={disabled ? 'default' : 'pointer'}
opacity={disabled ? 0.6 : 1}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ borderLeftWidth: 0, borderTopLeftRadius: 0, borderBottomLeftRadius: 0, height: '100%' }}
>
<HelpCircle className="w-3.5 h-3.5 text-gray-500 hover:text-primary-blue transition-colors" />
</button>
<Icon icon={HelpCircle} size={3.5} color="text-gray-500"
// eslint-disable-next-line gridpilot-rules/component-classification
className="hover:text-primary-blue transition-colors"
/>
</Box>
{/* Drop Rule Info Flyout */}
<InfoFlyout
isOpen={activeDropRuleFlyout === option.value}
onClose={() => setActiveDropRuleFlyout(null)}
title={ruleInfo.title}
anchorRef={{ current: dropRuleRefs.current[option.value] ?? dropInfoRef.current }}
anchorRef={{ current: (dropRuleRefs.current[option.value] as HTMLElement | null) ?? dropInfoRef.current }}
>
<div className="space-y-4">
<p className="text-xs text-gray-400">{ruleInfo.description}</p>
<Stack gap={4}>
<Text size="xs" color="text-gray-400" block>{ruleInfo.description}</Text>
<div className="space-y-2">
<div className="text-[10px] text-gray-500 uppercase tracking-wide">How It Works</div>
<ul className="space-y-1.5">
<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
mb={2}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
How It Works
</Text>
<Stack gap={1.5}>
{ruleInfo.details.map((detail, idx) => (
<li key={idx} className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3 h-3 text-performance-green shrink-0 mt-0.5" />
<span>{detail}</span>
</li>
<Box key={idx} display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3} color="text-performance-green" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">{detail}</Text>
</Box>
))}
</ul>
</div>
</Stack>
</Stack>
<div className="rounded-lg bg-deep-graphite border border-charcoal-outline/30 p-3">
<div className="flex items-center gap-2">
<span className="text-base">{option.emoji}</span>
<div>
<div className="text-[10px] text-gray-500">Example</div>
<div className="text-xs font-medium text-white">{ruleInfo.example}</div>
</div>
</div>
</div>
</div>
<Box rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30" p={3}>
<Box display="flex" alignItems="center" gap={2}>
<Text size="base">{option.emoji}</Text>
<Box>
<Text size="xs" color="text-gray-400" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Example
</Text>
<Text size="xs" weight="medium" color="text-white" block>{ruleInfo.example}</Text>
</Box>
</Box>
</Box>
</Stack>
</InfoFlyout>
</div>
</Box>
);
})}
{/* N Stepper - only show when needed */}
{needsN && (
<div className="flex items-center gap-1 ml-2">
<span className="text-xs text-gray-500 mr-1">N =</span>
<button
<Box display="flex" alignItems="center" gap={1} ml={2}>
<Text size="xs" color="text-gray-500" mr={1}>N =</Text>
<Box
as="button"
type="button"
disabled={disabled || (dropPolicy.n ?? 1) <= 1}
onClick={() => handleNChange(-1)}
className="flex h-7 w-7 items-center justify-center rounded-md bg-iron-gray border border-charcoal-outline text-gray-400 hover:text-white hover:border-primary-blue disabled:opacity-40 transition-colors"
display="flex"
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="md"
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
color="text-gray-400"
transition
hoverTextColor={!disabled ? 'text-white' : undefined}
hoverBorderColor={!disabled ? 'border-primary-blue' : undefined}
opacity={disabled || (dropPolicy.n ?? 1) <= 1 ? 0.4 : 1}
>
</button>
<div className="flex h-7 w-10 items-center justify-center rounded-md bg-iron-gray/50 border border-charcoal-outline/50">
<span className="text-sm font-semibold text-white">{dropPolicy.n ?? 1}</span>
</div>
<button
</Box>
<Box display="flex" h="7" w="10" alignItems="center" justifyContent="center" rounded="md" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/50">
<Text size="sm" weight="semibold" color="text-white">{dropPolicy.n ?? 1}</Text>
</Box>
<Box
as="button"
type="button"
disabled={disabled}
onClick={() => handleNChange(1)}
className="flex h-7 w-7 items-center justify-center rounded-md bg-iron-gray border border-charcoal-outline text-gray-400 hover:text-white hover:border-primary-blue disabled:opacity-40 transition-colors"
display="flex"
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="md"
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
color="text-gray-400"
transition
hoverTextColor={!disabled ? 'text-white' : undefined}
hoverBorderColor={!disabled ? 'border-primary-blue' : undefined}
opacity={disabled ? 0.4 : 1}
>
+
</button>
</div>
</Box>
</Box>
)}
</div>
</Box>
{/* Explanation text */}
<p className="text-xs text-gray-500">
<Text size="xs" color="text-gray-500" block>
{dropPolicy.strategy === 'none' && 'Every race result affects the championship standings.'}
{dropPolicy.strategy === 'bestNResults' && `Only your best ${dropPolicy.n ?? 1} results will count.`}
{dropPolicy.strategy === 'dropWorstN' && `Your worst ${dropPolicy.n ?? 1} results will be excluded.`}
</p>
</div>
</Text>
</Stack>
);
}
}