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

414 lines
16 KiB
TypeScript

'use client';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { Checkbox } from '@/ui/Checkbox';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Select } from '@/ui/Select';
import { Text } from '@/ui/Text';
import { AlertTriangle, Bell, Clock, Scale, Shield, Vote } from 'lucide-react';
import React from 'react';
interface LeagueStewardingSectionProps {
form: LeagueConfigFormModel;
onChange: (form: LeagueConfigFormModel) => void;
readOnly?: boolean;
}
type DecisionModeOption = {
value: NonNullable<LeagueConfigFormModel['stewarding']>['decisionMode'];
label: string;
description: string;
icon: React.ReactNode;
requiresVotes: boolean;
};
const decisionModeOptions: DecisionModeOption[] = [
{
value: 'single_steward',
label: 'Single Steward',
description: 'A single steward/admin makes all penalty decisions',
icon: <Icon icon={Shield} size={5} />,
requiresVotes: false,
},
{
value: 'committee_vote',
label: 'Committee Vote',
description: 'A group votes to uphold/dismiss protests',
icon: <Icon icon={Scale} size={5} />,
requiresVotes: true,
},
];
export function LeagueStewardingSection({
form,
onChange,
readOnly = false,
}: LeagueStewardingSectionProps) {
// Provide default stewarding settings if not present
const stewarding = form.stewarding ?? {
decisionMode: 'single_steward' as const,
requiredVotes: 2,
requireDefense: false,
defenseTimeLimit: 48,
voteTimeLimit: 72,
protestDeadlineHours: 48,
stewardingClosesHours: 168,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
};
const updateStewarding = (updates: Partial<NonNullable<LeagueConfigFormModel['stewarding']>>) => {
onChange({
...form,
stewarding: {
...stewarding,
...updates,
},
});
};
const selectedMode = decisionModeOptions.find((m) => m.value === stewarding.decisionMode);
return (
<Stack gap={8}>
{/* Decision Mode Selection */}
<Stack>
<Heading level={3} fontSize="sm" weight="semibold" color="text-white" mb={1}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Scale} size={4} color="text-primary-blue" />
How are protest decisions made?
</Stack>
</Heading>
<Text size="xs" color="text-gray-400" mb={4} block>
Choose who has the authority to issue penalties
</Text>
<Stack display="grid" gridCols={{ base: 1, sm: 2, lg: 3 }} gap={3}>
{decisionModeOptions.map((option) => (
<Stack
key={option.value}
as="button"
type="button"
disabled={readOnly}
onClick={() => updateStewarding({ decisionMode: option.value })}
position="relative"
display="flex"
flexDirection="col"
alignItems="start"
gap={2}
p={4}
rounded="xl"
border
borderWidth="2px"
transition
textAlign="left"
borderColor={stewarding.decisionMode === option.value ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={stewarding.decisionMode === option.value ? 'bg-primary-blue/5' : 'bg-iron-gray/30'}
shadow={stewarding.decisionMode === option.value ? '0_0_16px_rgba(25,140,255,0.15)' : undefined}
hoverBorderColor={!readOnly && stewarding.decisionMode !== option.value ? 'border-gray-500' : undefined}
opacity={readOnly ? 0.6 : 1}
cursor={readOnly ? 'not-allowed' : 'pointer'}
>
<Stack
p={2}
rounded="lg"
bg={stewarding.decisionMode === option.value ? 'bg-primary-blue/20' : 'bg-charcoal-outline/50'}
color={stewarding.decisionMode === option.value ? 'text-primary-blue' : 'text-gray-400'}
>
{option.icon}
</Stack>
<Stack>
<Text size="sm" weight="medium" color="text-white" block>{option.label}</Text>
<Text size="xs" color="text-gray-400" mt={0.5} block>{option.description}</Text>
</Stack>
{stewarding.decisionMode === option.value && (
<Stack position="absolute" top="2" right="2" w="2" h="2" rounded="full" bg="bg-primary-blue" />
)}
</Stack>
))}
</Stack>
</Stack>
{/* Vote Requirements (conditional) */}
{selectedMode?.requiresVotes && (
<Stack p={4} rounded="xl" bg="bg-iron-gray/40" border borderColor="border-charcoal-outline">
<Stack gap={4}>
<Heading level={4} fontSize="sm" weight="medium" color="text-white">
<Stack direction="row" align="center" gap={2}>
<Icon icon={Vote} size={4} color="text-primary-blue" />
Voting Configuration
</Stack>
</Heading>
<Stack display="grid" gridCols={{ base: 1, sm: 2 }} gap={4}>
<Stack>
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1.5}>
Required votes to uphold
</Text>
<Select
value={stewarding.requiredVotes?.toString() ?? '2'}
onChange={(e) => updateStewarding({ requiredVotes: parseInt(e.target.value, 10) })}
disabled={readOnly}
options={[
{ value: '1', label: '1 vote' },
{ value: '2', label: '2 votes' },
{ value: '3', label: '3 votes (majority of 5)' },
{ value: '4', label: '4 votes' },
{ value: '5', label: '5 votes' },
]}
/>
</Stack>
<Stack>
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1.5}>
Voting time limit
</Text>
<Select
value={stewarding.voteTimeLimit?.toString()}
onChange={(e) => updateStewarding({ voteTimeLimit: parseInt(e.target.value, 10) })}
disabled={readOnly}
options={[
{ value: '24', label: '24 hours' },
{ value: '48', label: '48 hours' },
{ value: '72', label: '72 hours (3 days)' },
{ value: '96', label: '96 hours (4 days)' },
{ value: '168', label: '168 hours (7 days)' },
]}
/>
</Stack>
</Stack>
</Stack>
</Stack>
)}
{/* Defense Settings */}
<Stack>
<Heading level={3} fontSize="sm" weight="semibold" color="text-white" mb={1}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Shield} size={4} color="text-primary-blue" />
Defense Requirements
</Stack>
</Heading>
<Text size="xs" color="text-gray-400" mb={4} block>
Should accused drivers be required to submit a defense?
</Text>
<Stack display="grid" gridCols={{ base: 1, sm: 2 }} gap={3}>
<Stack
as="button"
type="button"
disabled={readOnly}
onClick={() => updateStewarding({ requireDefense: false })}
display="flex"
alignItems="center"
gap={3}
p={4}
rounded="xl"
border
borderWidth="2px"
transition
textAlign="left"
borderColor={!stewarding.requireDefense ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={!stewarding.requireDefense ? 'bg-primary-blue/5' : 'bg-iron-gray/30'}
hoverBorderColor={!readOnly && stewarding.requireDefense ? 'border-gray-500' : undefined}
opacity={readOnly ? 0.6 : 1}
cursor={readOnly ? 'not-allowed' : 'pointer'}
>
<Stack w="4" h="4" rounded="full" border borderWidth="2px" display="flex" alignItems="center" justifyContent="center" borderColor={!stewarding.requireDefense ? 'border-primary-blue' : 'border-gray-500'}>
{!stewarding.requireDefense && <Stack w="2" h="2" rounded="full" bg="bg-primary-blue" />}
</Stack>
<Stack>
<Text size="sm" weight="medium" color="text-white" block>Defense optional</Text>
<Text size="xs" color="text-gray-400" block>Proceed without waiting for defense</Text>
</Stack>
</Stack>
<Stack
as="button"
type="button"
disabled={readOnly}
onClick={() => updateStewarding({ requireDefense: true })}
display="flex"
alignItems="center"
gap={3}
p={4}
rounded="xl"
border
borderWidth="2px"
transition
textAlign="left"
borderColor={stewarding.requireDefense ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={stewarding.requireDefense ? 'bg-primary-blue/5' : 'bg-iron-gray/30'}
hoverBorderColor={!readOnly && !stewarding.requireDefense ? 'border-gray-500' : undefined}
opacity={readOnly ? 0.6 : 1}
cursor={readOnly ? 'not-allowed' : 'pointer'}
>
<Stack w="4" h="4" rounded="full" border borderWidth="2px" display="flex" alignItems="center" justifyContent="center" borderColor={stewarding.requireDefense ? 'border-primary-blue' : 'border-gray-500'}>
{stewarding.requireDefense && <Stack w="2" h="2" rounded="full" bg="bg-primary-blue" />}
</Stack>
<Stack>
<Text size="sm" weight="medium" color="text-white" block>Defense required</Text>
<Text size="xs" color="text-gray-400" block>Wait for defense before deciding</Text>
</Stack>
</Stack>
</Stack>
{stewarding.requireDefense && (
<Stack mt={4} p={4} rounded="xl" bg="bg-iron-gray/40" border borderColor="border-charcoal-outline">
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1.5}>
Defense time limit
</Text>
<Select
value={stewarding.defenseTimeLimit?.toString()}
onChange={(e) => updateStewarding({ defenseTimeLimit: parseInt(e.target.value, 10) })}
disabled={readOnly}
options={[
{ value: '24', label: '24 hours' },
{ value: '48', label: '48 hours (2 days)' },
{ value: '72', label: '72 hours (3 days)' },
{ value: '96', label: '96 hours (4 days)' },
]}
/>
<Text size="xs" color="text-gray-500" mt={2} block>
After this time, the decision can proceed without a defense
</Text>
</Stack>
)}
</Stack>
{/* Deadlines */}
<Stack>
<Heading level={3} fontSize="sm" weight="semibold" color="text-white" mb={1}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Clock} size={4} color="text-primary-blue" />
Deadlines
</Stack>
</Heading>
<Text size="xs" color="text-gray-400" mb={4} block>
Set time limits for filing protests and closing stewarding
</Text>
<Stack display="grid" gridCols={{ base: 1, sm: 2 }} gap={4}>
<Stack p={4} rounded="xl" bg="bg-iron-gray/40" border borderColor="border-charcoal-outline">
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1.5}>
Protest filing deadline (after race)
</Text>
<Select
value={stewarding.protestDeadlineHours?.toString()}
onChange={(e) => updateStewarding({ protestDeadlineHours: parseInt(e.target.value, 10) })}
disabled={readOnly}
options={[
{ value: '12', label: '12 hours' },
{ value: '24', label: '24 hours (1 day)' },
{ value: '48', label: '48 hours (2 days)' },
{ value: '72', label: '72 hours (3 days)' },
{ value: '168', label: '168 hours (7 days)' },
]}
/>
<Text size="xs" color="text-gray-500" mt={2} block>
Drivers cannot file protests after this time
</Text>
</Stack>
<Stack p={4} rounded="xl" bg="bg-iron-gray/40" border borderColor="border-charcoal-outline">
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1.5}>
Stewarding closes (after race)
</Text>
<Select
value={stewarding.stewardingClosesHours?.toString()}
onChange={(e) => updateStewarding({ stewardingClosesHours: parseInt(e.target.value, 10) })}
disabled={readOnly}
options={[
{ value: '72', label: '72 hours (3 days)' },
{ value: '96', label: '96 hours (4 days)' },
{ value: '168', label: '168 hours (7 days)' },
{ value: '336', label: '336 hours (14 days)' },
]}
/>
<Text size="xs" color="text-gray-500" mt={2} block>
All stewarding must be concluded by this time
</Text>
</Stack>
</Stack>
</Stack>
{/* Notifications */}
<Stack>
<Heading level={3} fontSize="sm" weight="semibold" color="text-white" mb={1}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Bell} size={4} color="text-primary-blue" />
Notifications
</Stack>
</Heading>
<Text size="xs" color="text-gray-400" mb={4} block>
Configure automatic notifications for involved parties
</Text>
<Stack gap={3}>
<Stack
p={4}
rounded="xl"
bg="bg-iron-gray/40"
border
borderColor="border-charcoal-outline"
transition
hoverBg={!readOnly ? 'bg-iron-gray/60' : undefined}
opacity={readOnly ? 0.6 : 1}
>
<Checkbox
label="Notify accused driver"
checked={stewarding.notifyAccusedOnProtest}
onChange={(checked) => updateStewarding({ notifyAccusedOnProtest: checked })}
disabled={readOnly}
/>
<Stack ml={7} mt={1}>
<Text size="xs" color="text-gray-400" block>
Send notification when a protest is filed against them
</Text>
</Stack>
</Stack>
<Stack
p={4}
rounded="xl"
bg="bg-iron-gray/40"
border
borderColor="border-charcoal-outline"
transition
hoverBg={!readOnly ? 'bg-iron-gray/60' : undefined}
opacity={readOnly ? 0.6 : 1}
>
<Checkbox
label="Notify voters"
checked={stewarding.notifyOnVoteRequired}
onChange={(checked) => updateStewarding({ notifyOnVoteRequired: checked })}
disabled={readOnly}
/>
<Stack ml={7} mt={1}>
<Text size="xs" color="text-gray-400" block>
Send notification to stewards/members when their vote is needed
</Text>
</Stack>
</Stack>
</Stack>
</Stack>
{/* Warning about strict settings */}
{stewarding.requireDefense && stewarding.decisionMode !== 'single_steward' && (
<Stack display="flex" alignItems="start" gap={3} p={4} rounded="xl" bg="bg-warning-amber/10" border borderColor="border-warning-amber/20">
<Icon icon={AlertTriangle} size={5} color="text-warning-amber" mt={0.5} />
<Stack>
<Text size="sm" weight="medium" color="text-warning-amber" block>Strict settings enabled</Text>
<Text size="xs" color="text-warning-amber" opacity={0.8} mt={1} block>
Requiring defense and voting may delay penalty decisions. Make sure your stewards/members
are active enough to meet the deadlines.
</Text>
</Stack>
</Stack>
)}
</Stack>
);
}