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,11 +1,6 @@
import { Trophy, Sparkles, LucideIcon } from 'lucide-react';
import { Heading } from '@/ui/Heading';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { EmptyState as UiEmptyState } from '@/ui/EmptyState';
interface EmptyStateProps {
title: string;
@@ -15,7 +10,6 @@ interface EmptyStateProps {
actionLabel?: string;
onAction?: () => void;
children?: React.ReactNode;
className?: string;
}
export function EmptyState({
@@ -26,38 +20,20 @@ export function EmptyState({
actionLabel,
onAction,
children,
className,
}: EmptyStateProps) {
return (
<Card className={className}>
<Box textAlign="center" py={16}>
<Box maxWidth="md" mx="auto">
<Box height={16} width={16} mx="auto" display="flex" center rounded="2xl" backgroundColor="primary-blue" opacity={0.1} border borderColor="primary-blue" mb={6}>
<Icon icon={icon} size={8} color="text-primary-blue" />
</Box>
<Box mb={3}>
<Heading level={2}>
{title}
</Heading>
</Box>
<Box mb={8}>
<Text color="text-gray-400">
{description}
</Text>
</Box>
{children}
{actionLabel && onAction && (
<Button
variant="primary"
onClick={onAction}
icon={<Icon icon={actionIcon} size={4} />}
className="mx-auto"
>
{actionLabel}
</Button>
)}
</Box>
</Box>
<Card>
<UiEmptyState
title={title}
description={description}
icon={icon}
action={actionLabel && onAction ? {
label: actionLabel,
onClick: onAction,
icon: actionIcon,
} : undefined}
/>
{children}
</Card>
);
}

View File

@@ -1,100 +1,72 @@
'use client';
import React from 'react';
import { AlertTriangle, TestTube, CheckCircle2 } from 'lucide-react';
import Button from '@/ui/Button';
import { TestTube } from 'lucide-react';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Modal } from '@/ui/Modal';
import { InfoBanner } from '@/ui/InfoBanner';
import { Box } from '@/ui/Box';
import { ModalIcon } from '@/ui/ModalIcon';
interface EndRaceModalProps {
raceId: string;
raceName: string;
onConfirm: () => void;
onCancel: () => void;
isOpen: boolean;
}
export default function EndRaceModal({ raceId, raceName, onConfirm, onCancel }: EndRaceModalProps) {
export function EndRaceModal({ raceId, raceName, onConfirm, onCancel, isOpen }: EndRaceModalProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="w-full max-w-md bg-iron-gray rounded-xl border border-charcoal-outline shadow-2xl">
<div className="p-6">
{/* Header */}
<div className="flex items-center gap-3 mb-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-warning-amber/10 border border-warning-amber/20">
<TestTube className="w-6 h-6 text-warning-amber" />
</div>
<div>
<h2 className="text-xl font-bold text-white">Development Test Function</h2>
<p className="text-sm text-gray-400">End Race & Process Results</p>
</div>
</div>
<Modal
isOpen={isOpen}
onOpenChange={(open) => !open && onCancel()}
title="Development Test Function"
description="End Race & Process Results"
icon={
<ModalIcon
icon={TestTube}
color="text-warning-amber"
bgColor="bg-warning-amber/10"
borderColor="border-warning-amber/20"
/>
}
primaryActionLabel="Run Test"
onPrimaryAction={onConfirm}
secondaryActionLabel="Cancel"
onSecondaryAction={onCancel}
footer={
<Text size="xs" color="text-gray-500" align="center" block>
This action cannot be undone. Use only for testing purposes.
</Text>
}
>
<Stack gap={4}>
<InfoBanner type="warning" title="Development Only Feature">
This is a development/testing function to simulate ending a race and processing results.
It will generate realistic race results, update driver ratings, and calculate final standings.
</InfoBanner>
{/* Content */}
<div className="space-y-4 mb-6">
<div className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-warning-amber mt-0.5 flex-shrink-0" />
<div>
<h3 className="text-sm font-semibold text-white mb-1">Development Only Feature</h3>
<p className="text-sm text-gray-300 leading-relaxed">
This is a development/testing function to simulate ending a race and processing results.
It will generate realistic race results, update driver ratings, and calculate final standings.
</p>
</div>
</div>
</div>
<InfoBanner type="success" title="What This Does">
<Stack as="ul" gap={1}>
<Text as="li" size="sm" color="text-gray-300"> Marks the race as completed</Text>
<Text as="li" size="sm" color="text-gray-300"> Generates realistic finishing positions</Text>
<Text as="li" size="sm" color="text-gray-300"> Updates driver ratings based on performance</Text>
<Text as="li" size="sm" color="text-gray-300"> Calculates championship points</Text>
<Text as="li" size="sm" color="text-gray-300"> Updates league standings</Text>
</Stack>
</InfoBanner>
<div className="p-4 rounded-lg bg-performance-green/10 border border-performance-green/20">
<div className="flex items-start gap-3">
<CheckCircle2 className="w-5 h-5 text-performance-green mt-0.5 flex-shrink-0" />
<div>
<h3 className="text-sm font-semibold text-white mb-1">What This Does</h3>
<ul className="text-sm text-gray-300 space-y-1">
<li> Marks the race as completed</li>
<li> Generates realistic finishing positions</li>
<li> Updates driver ratings based on performance</li>
<li> Calculates championship points</li>
<li> Updates league standings</li>
</ul>
</div>
</div>
</div>
<div className="text-center">
<p className="text-sm text-gray-400">
Race: <span className="text-white font-medium">{raceName}</span>
</p>
<p className="text-xs text-gray-500 mt-1">
ID: {raceId}
</p>
</div>
</div>
{/* Actions */}
<div className="flex gap-3">
<Button
variant="secondary"
onClick={onCancel}
className="flex-1"
>
Cancel
</Button>
<Button
variant="primary"
onClick={onConfirm}
className="flex-1 bg-performance-green hover:bg-performance-green/80"
>
<TestTube className="w-4 h-4 mr-2" />
Run Test
</Button>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-charcoal-outline">
<p className="text-xs text-gray-500 text-center">
This action cannot be undone. Use only for testing purposes.
</p>
</div>
</div>
</div>
</div>
<Box textAlign="center">
<Text size="sm" color="text-gray-400">
Race: <Text color="text-white" weight="medium">{raceName}</Text>
</Text>
<Text size="xs" color="text-gray-500" block mt={1}>
ID: {raceId}
</Text>
</Box>
</Stack>
</Modal>
);
}
}

View File

@@ -1,10 +1,13 @@
'use client';
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { getMembership } from '@/lib/leagueMembership';
import { useState } from 'react';
import { useLeagueMembershipMutation } from "@/lib/hooks/league/useLeagueMembershipMutation";
import Button from '../ui/Button';
import { useLeagueMembershipMutation } from "@/hooks/league/useLeagueMembershipMutation";
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Modal } from '@/ui/Modal';
interface JoinLeagueButtonProps {
leagueId: string;
@@ -12,7 +15,7 @@ interface JoinLeagueButtonProps {
onMembershipChange?: () => void;
}
export default function JoinLeagueButton({
export function JoinLeagueButton({
leagueId,
isInviteOnly = false,
onMembershipChange,
@@ -93,7 +96,7 @@ export default function JoinLeagueButton({
const isDisabled = membership?.role === 'owner' || joinLeague.isPending || leaveLeague.isPending;
return (
<>
<Box>
<Button
variant={getButtonVariant()}
onClick={() => {
@@ -104,58 +107,41 @@ export default function JoinLeagueButton({
}
}}
disabled={isDisabled}
className="w-full"
fullWidth
>
{(joinLeague.isPending || leaveLeague.isPending) ? 'Processing...' : getButtonText()}
</Button>
{error && (
<p className="mt-2 text-sm text-red-400">{error}</p>
<Text size="sm" color="text-red-400" mt={2} block>{error}</Text>
)}
{/* Confirmation Dialog */}
{showConfirmDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-iron-gray border border-charcoal-outline rounded-lg max-w-md w-full p-6">
<h3 className="text-xl font-semibold text-white mb-4">
{dialogAction === 'leave' ? 'Leave League' : dialogAction === 'request' ? 'Request to Join' : 'Join League'}
</h3>
<p className="text-gray-400 mb-6">
{dialogAction === 'leave'
? 'Are you sure you want to leave this league? You can rejoin later.'
: dialogAction === 'request'
? 'Your join request will be sent to the league admins for approval.'
: 'Are you sure you want to join this league?'}
</p>
<Modal
isOpen={showConfirmDialog}
onOpenChange={setShowConfirmDialog}
title={dialogAction === 'leave' ? 'Leave League' : dialogAction === 'request' ? 'Request to Join' : 'Join League'}
primaryActionLabel={(joinLeague.isPending || leaveLeague.isPending) ? 'Processing...' : 'Confirm'}
onPrimaryAction={dialogAction === 'leave' ? handleLeave : handleJoin}
secondaryActionLabel="Cancel"
onSecondaryAction={closeDialog}
>
<Box>
<Text color="text-gray-400" block mb={6}>
{dialogAction === 'leave'
? 'Are you sure you want to leave this league? You can rejoin later.'
: dialogAction === 'request'
? 'Your join request will be sent to the league admins for approval.'
: 'Are you sure you want to join this league?'}
</Text>
{error && (
<div className="mb-4 p-3 rounded bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
{error}
</div>
)}
<div className="flex gap-3">
<Button
variant={dialogAction === 'leave' ? 'danger' : 'primary'}
onClick={dialogAction === 'leave' ? handleLeave : handleJoin}
disabled={joinLeague.isPending || leaveLeague.isPending}
className="flex-1"
>
{(joinLeague.isPending || leaveLeague.isPending) ? 'Processing...' : 'Confirm'}
</Button>
<Button
variant="secondary"
onClick={closeDialog}
disabled={joinLeague.isPending || leaveLeague.isPending}
className="flex-1"
>
Cancel
</Button>
</div>
</div>
</div>
)}
</>
{error && (
<Box mb={4} p={3} rounded="md" bg="bg-red-500/10" border borderColor="border-red-500/30">
<Text size="sm" color="text-red-400">{error}</Text>
</Box>
)}
</Box>
</Modal>
</Box>
);
}

View File

@@ -1,7 +1,11 @@
'use client';
import { Calendar, Award, UserPlus, UserMinus, Shield, Flag, AlertTriangle } from 'lucide-react';
import { useLeagueRaces } from "@/lib/hooks/league/useLeagueRaces";
import React, { useMemo } from 'react';
import { Calendar, UserPlus, UserMinus, Shield, Flag, AlertTriangle } from 'lucide-react';
import { useLeagueRaces } from "@/hooks/league/useLeagueRaces";
import { ActivityFeedItem } from '@/ui/ActivityFeedItem';
import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { processLeagueActivities } from '@/lib/services/league/LeagueActivityService';
export type LeagueActivity =
| { type: 'race_completed'; raceId: string; raceName: string; timestamp: Date }
@@ -29,67 +33,36 @@ function timeAgo(timestamp: Date): string {
return timestamp.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
export default function LeagueActivityFeed({ leagueId, limit = 10 }: LeagueActivityFeedProps) {
export function LeagueActivityFeed({ leagueId, limit = 10 }: LeagueActivityFeedProps) {
const { data: raceList = [], isLoading } = useLeagueRaces(leagueId);
const activities: LeagueActivity[] = [];
if (!isLoading && raceList.length > 0) {
const completedRaces = raceList
.filter((r) => r.status === 'completed')
.sort((a, b) => new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime())
.slice(0, 5);
const upcomingRaces = raceList
.filter((r) => r.status === 'scheduled')
.sort((a, b) => new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime())
.slice(0, 3);
for (const race of completedRaces) {
activities.push({
type: 'race_completed',
raceId: race.id,
raceName: `${race.track} - ${race.car}`,
timestamp: new Date(race.scheduledAt),
});
}
for (const race of upcomingRaces) {
activities.push({
type: 'race_scheduled',
raceId: race.id,
raceName: `${race.track} - ${race.car}`,
timestamp: new Date(new Date(race.scheduledAt).getTime() - 7 * 24 * 60 * 60 * 1000), // Simulate schedule announcement
});
}
// Sort all activities by timestamp
activities.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
activities.splice(limit); // Limit results
}
const activities = useMemo(() => {
if (isLoading || raceList.length === 0) return [];
return processLeagueActivities(raceList, limit);
}, [raceList, isLoading, limit]);
if (isLoading) {
return (
<div className="text-center text-gray-400 py-8">
<Text color="text-gray-400" textAlign="center" block py={8}>
Loading activities...
</div>
</Text>
);
}
if (activities.length === 0) {
return (
<div className="text-center text-gray-400 py-8">
<Text color="text-gray-400" textAlign="center" block py={8}>
No recent activity
</div>
</Text>
);
}
return (
<div className="space-y-4">
<Stack gap={0}>
{activities.map((activity, index) => (
<ActivityItem key={`${activity.type}-${index}`} activity={activity} />
))}
</div>
</Stack>
);
}
@@ -97,17 +70,17 @@ function ActivityItem({ activity }: { activity: LeagueActivity }) {
const getIcon = () => {
switch (activity.type) {
case 'race_completed':
return <Flag className="w-4 h-4 text-performance-green" />;
return <Icon icon={Flag} size={4} color="var(--performance-green)" />;
case 'race_scheduled':
return <Calendar className="w-4 h-4 text-primary-blue" />;
return <Icon icon={Calendar} size={4} color="var(--primary-blue)" />;
case 'penalty_applied':
return <AlertTriangle className="w-4 h-4 text-warning-amber" />;
return <Icon icon={AlertTriangle} size={4} color="var(--warning-amber)" />;
case 'member_joined':
return <UserPlus className="w-4 h-4 text-performance-green" />;
return <Icon icon={UserPlus} size={4} color="var(--performance-green)" />;
case 'member_left':
return <UserMinus className="w-4 h-4 text-gray-400" />;
return <Icon icon={UserMinus} size={4} color="var(--text-gray-400)" />;
case 'role_changed':
return <Shield className="w-4 h-4 text-primary-blue" />;
return <Icon icon={Shield} size={4} color="var(--primary-blue)" />;
}
};
@@ -116,64 +89,56 @@ function ActivityItem({ activity }: { activity: LeagueActivity }) {
case 'race_completed':
return (
<>
<span className="text-white font-medium">Race Completed</span>
<span className="text-gray-400"> · {activity.raceName}</span>
<Text weight="medium" color="text-white">Race Completed</Text>
<Text color="text-gray-400"> · {activity.raceName}</Text>
</>
);
case 'race_scheduled':
return (
<>
<span className="text-white font-medium">Race Scheduled</span>
<span className="text-gray-400"> · {activity.raceName}</span>
<Text weight="medium" color="text-white">Race Scheduled</Text>
<Text color="text-gray-400"> · {activity.raceName}</Text>
</>
);
case 'penalty_applied':
return (
<>
<span className="text-white font-medium">{activity.driverName}</span>
<span className="text-gray-400"> received a </span>
<span className="text-warning-amber">{activity.points}-point penalty</span>
<span className="text-gray-400"> · {activity.reason}</span>
<Text weight="medium" color="text-white">{activity.driverName}</Text>
<Text color="text-gray-400"> received a </Text>
<Text color="text-warning-amber">{activity.points}-point penalty</Text>
<Text color="text-gray-400"> · {activity.reason}</Text>
</>
);
case 'member_joined':
return (
<>
<span className="text-white font-medium">{activity.driverName}</span>
<span className="text-gray-400"> joined the league</span>
<Text weight="medium" color="text-white">{activity.driverName}</Text>
<Text color="text-gray-400"> joined the league</Text>
</>
);
case 'member_left':
return (
<>
<span className="text-white font-medium">{activity.driverName}</span>
<span className="text-gray-400"> left the league</span>
<Text weight="medium" color="text-white">{activity.driverName}</Text>
<Text color="text-gray-400"> left the league</Text>
</>
);
case 'role_changed':
return (
<>
<span className="text-white font-medium">{activity.driverName}</span>
<span className="text-gray-400"> promoted to </span>
<span className="text-primary-blue">{activity.newRole}</span>
<Text weight="medium" color="text-white">{activity.driverName}</Text>
<Text color="text-gray-400"> promoted to </Text>
<Text color="text-primary-blue">{activity.newRole}</Text>
</>
);
}
};
return (
<div className="flex items-start gap-3 py-3 border-b border-charcoal-outline/30 last:border-0">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-iron-gray/50 flex items-center justify-center">
{getIcon()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm leading-relaxed">
{getContent()}
</p>
<p className="text-xs text-gray-500 mt-1">
{timeAgo(activity.timestamp)}
</p>
</div>
</div>
<ActivityFeedItem
icon={getIcon()}
content={getContent()}
timestamp={timeAgo(activity.timestamp)}
/>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { FileText, Gamepad2, AlertCircle, Check } from 'lucide-react';
import { FileText, Gamepad2, Check } from 'lucide-react';
import { Input } from '@/ui/Input';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { Box } from '@/ui/Box';
@@ -12,6 +12,7 @@ import { Icon } from '@/ui/Icon';
import { Grid } from '@/ui/Grid';
import { Surface } from '@/ui/Surface';
import { Button } from '@/ui/Button';
import { TextArea } from '@/ui/TextArea';
interface LeagueBasicsSectionProps {
form: LeagueConfigFormModel;
@@ -61,15 +62,15 @@ export function LeagueBasicsSection({
{/* League name */}
<Stack gap={3}>
<Text as="label" size="sm" weight="medium" color="text-gray-300">
<Stack direction="row" align="center" gap={2}>
<Icon icon={FileText} size={4} color="text-primary-blue" />
League name *
</Stack>
</Text>
<Input
label={
<Stack direction="row" align="center" gap={2}>
<Icon icon={FileText} size={4} color="var(--primary-blue)" />
<Text size="sm" weight="medium" color="text-gray-300">League name *</Text>
</Stack>
}
value={basics.name}
onChange={(e) => updateBasics({ name: e.target.value })}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateBasics({ name: e.target.value })}
placeholder="e.g., GridPilot Sprint Series"
variant={errors?.name ? 'error' : 'default'}
errorMessage={errors?.name}
@@ -93,8 +94,12 @@ export function LeagueBasicsSection({
onClick={() => updateBasics({ name })}
variant="secondary"
size="sm"
className="h-auto py-0.5 px-2 rounded-full text-xs"
disabled={disabled}
rounded="full"
fontSize="0.75rem"
px={2}
py={0.5}
h="auto"
>
{name}
</Button>
@@ -104,74 +109,61 @@ export function LeagueBasicsSection({
</Stack>
{/* Description - Now Required */}
<Stack gap={3}>
<Text as="label" size="sm" weight="medium" color="text-gray-300">
<TextArea
label={
<Stack direction="row" align="center" gap={2}>
<Icon icon={FileText} size={4} color="text-primary-blue" />
Tell your story *
<Icon icon={FileText} size={4} color="var(--primary-blue)" />
<Text size="sm" weight="medium" color="text-gray-300">Tell your story *</Text>
</Stack>
</Text>
<Box position="relative">
<textarea
value={basics.description ?? ''}
onChange={(e) =>
updateBasics({
description: e.target.value,
})
}
rows={4}
disabled={disabled}
className={`block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue text-sm disabled:opacity-60 disabled:cursor-not-allowed transition-all duration-150 ${
errors?.description ? 'ring-warning-amber' : 'ring-charcoal-outline'
}`}
placeholder="What makes your league special? Tell drivers what to expect..."
/>
</Box>
{errors?.description && (
<Text size="xs" color="text-warning-amber">
<Stack direction="row" align="center" gap={1.5}>
<Icon icon={AlertCircle} size={3} />
{errors.description}
</Stack>
}
value={basics.description ?? ''}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
updateBasics({
description: e.target.value,
})
}
rows={4}
disabled={disabled}
variant={errors?.description ? 'error' : 'default'}
errorMessage={errors?.description}
placeholder="What makes your league special? Tell drivers what to expect..."
/>
<Surface variant="muted" rounded="lg" border padding={4}>
<Box mb={3}>
<Text size="xs" color="text-gray-400">
<Text weight="medium" color="text-gray-300">Great descriptions include:</Text>
</Text>
)}
<Surface variant="muted" rounded="lg" border padding={4}>
<Box mb={3}>
<Text size="xs" color="text-gray-400">
<Text weight="medium" color="text-gray-300">Great descriptions include:</Text>
</Text>
</Box>
<Grid cols={3} gap={3}>
{[
'Racing style & pace',
'Schedule & timezone',
'Community vibe'
].map(item => (
<Stack key={item} direction="row" align="start" gap={2}>
<Icon icon={Check} size={3.5} color="text-performance-green" className="mt-0.5" />
<Text size="xs" color="text-gray-400">{item}</Text>
</Stack>
))}
</Grid>
</Surface>
</Stack>
</Box>
<Grid cols={3} gap={3}>
{[
'Racing style & pace',
'Schedule & timezone',
'Community vibe'
].map(item => (
<Stack key={item} direction="row" align="start" gap={2}>
<Icon icon={Check} size={3.5} color="var(--performance-green)" mt={0.5} />
<Text size="xs" color="text-gray-400">{item}</Text>
</Stack>
))}
</Grid>
</Surface>
{/* Game Platform */}
<Stack gap={2}>
<Text as="label" size="sm" weight="medium" color="text-gray-300">
<Stack direction="row" align="center" gap={2}>
<Icon icon={Gamepad2} size={4} color="text-gray-400" />
Game platform
</Stack>
<Input
label={
<Stack direction="row" align="center" gap={2}>
<Icon icon={Gamepad2} size={4} color="var(--text-gray-400)" />
<Text size="sm" weight="medium" color="text-gray-300">Game platform</Text>
</Stack>
}
value="iRacing"
disabled
/>
<Text size="xs" color="text-gray-500">
More platforms soon
</Text>
<Box position="relative">
<Input value="iRacing" disabled />
<Box position="absolute" right={3} top="50%" style={{ transform: 'translateY(-50%)' }}>
<Text size="xs" color="text-gray-500">
More platforms soon
</Text>
</Box>
</Box>
</Stack>
</Stack>
);

View File

@@ -1,289 +0,0 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import {
Trophy,
Users,
Flag,
Award,
Gamepad2,
Calendar,
ChevronRight,
Sparkles,
} from 'lucide-react';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import { getLeagueCoverClasses } from '@/lib/leagueCovers';
import PlaceholderImage from '@/ui/PlaceholderImage';
import { getMediaUrl } from '@/lib/utilities/media';
interface LeagueCardProps {
league: LeagueSummaryViewModel;
onClick?: () => void;
}
function getChampionshipIcon(type?: string) {
switch (type) {
case 'driver':
return Trophy;
case 'team':
return Users;
case 'nations':
return Flag;
case 'trophy':
return Award;
default:
return Trophy;
}
}
function getChampionshipLabel(type?: string) {
switch (type) {
case 'driver':
return 'Driver';
case 'team':
return 'Team';
case 'nations':
return 'Nations';
case 'trophy':
return 'Trophy';
default:
return 'Championship';
}
}
function getCategoryLabel(category?: string): string {
if (!category) return '';
switch (category) {
case 'driver':
return 'Driver';
case 'team':
return 'Team';
case 'nations':
return 'Nations';
case 'trophy':
return 'Trophy';
case 'endurance':
return 'Endurance';
case 'sprint':
return 'Sprint';
default:
return category.charAt(0).toUpperCase() + category.slice(1);
}
}
function getCategoryColor(category?: string): string {
if (!category) return 'bg-gray-500/20 text-gray-400 border-gray-500/30';
switch (category) {
case 'driver':
return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
case 'team':
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
case 'nations':
return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'trophy':
return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30';
case 'endurance':
return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
case 'sprint':
return 'bg-red-500/20 text-red-400 border-red-500/30';
default:
return 'bg-gray-500/20 text-gray-400 border-gray-500/30';
}
}
function getGameColor(gameId?: string): string {
switch (gameId) {
case 'iracing':
return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
case 'acc':
return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'f1-23':
case 'f1-24':
return 'bg-red-500/20 text-red-400 border-red-500/30';
default:
return 'bg-primary-blue/20 text-primary-blue border-primary-blue/30';
}
}
function isNewLeague(createdAt: string | Date): boolean {
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
return new Date(createdAt) > oneWeekAgo;
}
export default function LeagueCard({ league, onClick }: LeagueCardProps) {
const coverUrl = getMediaUrl('league-cover', league.id);
const logoUrl = league.logoUrl;
const ChampionshipIcon = getChampionshipIcon(league.scoring?.primaryChampionshipType);
const championshipLabel = getChampionshipLabel(league.scoring?.primaryChampionshipType);
const gameColorClass = getGameColor(league.scoring?.gameId);
const isNew = isNewLeague(league.createdAt);
const isTeamLeague = league.maxTeams && league.maxTeams > 0;
const categoryLabel = getCategoryLabel(league.category);
const categoryColorClass = getCategoryColor(league.category);
// Calculate fill percentage - use teams for team leagues, drivers otherwise
const usedSlots = isTeamLeague ? (league.usedTeamSlots ?? 0) : (league.usedDriverSlots ?? 0);
const maxSlots = isTeamLeague ? (league.maxTeams ?? 0) : (league.maxDrivers ?? 0);
const fillPercentage = maxSlots > 0 ? (usedSlots / maxSlots) * 100 : 0;
const hasOpenSlots = maxSlots > 0 && usedSlots < maxSlots;
// Determine slot label based on championship type
const getSlotLabel = () => {
if (isTeamLeague) return 'Teams';
if (league.scoring?.primaryChampionshipType === 'nations') return 'Nations';
return 'Drivers';
};
const slotLabel = getSlotLabel();
return (
<div
className="group relative cursor-pointer h-full"
onClick={onClick}
>
{/* Card Container */}
<div className="relative h-full rounded-xl bg-iron-gray border border-charcoal-outline overflow-hidden transition-all duration-200 hover:border-primary-blue/50 hover:shadow-[0_0_30px_rgba(25,140,255,0.15)] hover:bg-iron-gray/80">
{/* Cover Image */}
<div className="relative h-32 overflow-hidden">
<img
src={coverUrl}
alt={`${league.name} cover`}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
decoding="async"
/>
{/* Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-iron-gray via-iron-gray/60 to-transparent" />
{/* Badges - Top Left */}
<div className="absolute top-3 left-3 flex items-center gap-2">
{isNew && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-performance-green/20 text-performance-green border border-performance-green/30">
<Sparkles className="w-3 h-3" />
NEW
</span>
)}
{league.scoring?.gameName && (
<span className={`px-2 py-0.5 rounded-full text-[10px] font-semibold border ${gameColorClass}`}>
{league.scoring.gameName}
</span>
)}
{league.category && (
<span className={`px-2 py-0.5 rounded-full text-[10px] font-semibold border ${categoryColorClass}`}>
{categoryLabel}
</span>
)}
</div>
{/* Championship Type Badge - Top Right */}
<div className="absolute top-3 right-3">
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-deep-graphite/80 text-gray-300 border border-charcoal-outline">
<ChampionshipIcon className="w-3 h-3" />
{championshipLabel}
</span>
</div>
{/* Logo */}
<div className="absolute left-4 -bottom-6 z-10">
<div className="w-12 h-12 rounded-lg overflow-hidden border-2 border-iron-gray bg-deep-graphite shadow-xl">
{logoUrl ? (
<img
src={logoUrl}
alt={`${league.name} logo`}
width={48}
height={48}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
) : (
<PlaceholderImage size={48} />
)}
</div>
</div>
</div>
{/* Content */}
<div className="pt-8 px-4 pb-4 flex flex-col flex-1">
{/* Title & Description */}
<h3 className="text-base font-semibold text-white mb-1 line-clamp-1 group-hover:text-primary-blue transition-colors">
{league.name}
</h3>
<p className="text-xs text-gray-500 line-clamp-2 mb-3 h-8">
{league.description || 'No description available'}
</p>
{/* Stats Row */}
<div className="flex items-center gap-3 mb-3">
{/* Primary Slots (Drivers/Teams/Nations) */}
<div className="flex-1">
<div className="flex items-center justify-between text-[10px] text-gray-500 mb-1">
<span>{slotLabel}</span>
<span className="text-gray-400">
{usedSlots}/{maxSlots || '∞'}
</span>
</div>
<div className="h-1.5 rounded-full bg-charcoal-outline overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
fillPercentage >= 90
? 'bg-warning-amber'
: fillPercentage >= 70
? 'bg-primary-blue'
: 'bg-performance-green'
}`}
style={{ width: `${Math.min(fillPercentage, 100)}%` }}
/>
</div>
</div>
{/* Open Slots Badge */}
{hasOpenSlots && (
<div className="flex items-center gap-1 px-2 py-1 rounded-lg bg-neon-aqua/10 border border-neon-aqua/20">
<span className="w-1.5 h-1.5 rounded-full bg-neon-aqua animate-pulse" />
<span className="text-[10px] text-neon-aqua font-medium">
{maxSlots - usedSlots} open
</span>
</div>
)}
</div>
{/* Driver count for team leagues */}
{isTeamLeague && (
<div className="flex items-center gap-2 mb-3 text-[10px] text-gray-500">
<Users className="w-3 h-3" />
<span>
{league.usedDriverSlots ?? 0}/{league.maxDrivers ?? '∞'} drivers
</span>
</div>
)}
{/* Spacer to push footer to bottom */}
<div className="flex-1" />
{/* Footer Info */}
<div className="flex items-center justify-between pt-3 border-t border-charcoal-outline/50 mt-auto">
<div className="flex items-center gap-3 text-[10px] text-gray-500">
{league.timingSummary && (
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{league.timingSummary.split('•')[1]?.trim() || league.timingSummary}
</span>
)}
</div>
{/* View Arrow */}
<div className="flex items-center gap-1 text-[10px] text-gray-500 group-hover:text-primary-blue transition-colors">
<span>View</span>
<ChevronRight className="w-3 h-3 transition-transform group-hover:translate-x-0.5" />
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,12 +1,7 @@
'use client';
import React from 'react';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Grid } from '@/ui/Grid';
import { Surface } from '@/ui/Surface';
import { HorizontalStatCard } from '@/ui/HorizontalStatCard';
interface LeagueChampionshipStatsProps {
standings: Array<{
@@ -29,44 +24,29 @@ export function LeagueChampionshipStats({ standings, drivers }: LeagueChampionsh
return (
<Grid cols={3} gap={4}>
<Card>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="full" padding={3} style={{ backgroundColor: 'rgba(250, 204, 21, 0.1)' }}>
<Text size="2xl">🏆</Text>
</Surface>
<Box>
<Text size="xs" color="text-gray-400" block mb={1}>Championship Leader</Text>
<Text weight="bold" color="text-white" block>{drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'}</Text>
<Text size="sm" color="text-warning-amber" weight="medium">{leader?.totalPoints || 0} points</Text>
</Box>
</Stack>
</Card>
<HorizontalStatCard
label="Championship Leader"
value={drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'}
subValue={`${leader?.totalPoints || 0} points`}
icon={<Text size="2xl">🏆</Text>}
iconBgColor="rgba(250, 204, 21, 0.1)"
/>
<Card>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="full" padding={3} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)' }}>
<Text size="2xl">🏁</Text>
</Surface>
<Box>
<Text size="xs" color="text-gray-400" block mb={1}>Races Completed</Text>
<Text size="2xl" weight="bold" color="text-white" block>{totalRaces}</Text>
<Text size="sm" color="text-gray-400">Season in progress</Text>
</Box>
</Stack>
</Card>
<HorizontalStatCard
label="Races Completed"
value={totalRaces}
subValue="Season in progress"
icon={<Text size="2xl">🏁</Text>}
iconBgColor="rgba(59, 130, 246, 0.1)"
/>
<Card>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="full" padding={3} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)' }}>
<Text size="2xl">👥</Text>
</Surface>
<Box>
<Text size="xs" color="text-gray-400" block mb={1}>Active Drivers</Text>
<Text size="2xl" weight="bold" color="text-white" block>{standings.length}</Text>
<Text size="sm" color="text-gray-400">Competing for points</Text>
</Box>
</Stack>
</Card>
<HorizontalStatCard
label="Active Drivers"
value={standings.length}
subValue="Competing for points"
icon={<Text size="2xl">👥</Text>}
iconBgColor="rgba(16, 185, 129, 0.1)"
/>
</Grid>
);
}

View File

@@ -1,27 +0,0 @@
/**
* LeagueCover
*
* Pure UI component for displaying league cover images.
* Renders an image with fallback on error.
*/
export interface LeagueCoverProps {
leagueId: string;
alt: string;
className?: string;
}
export function LeagueCover({ leagueId, alt, className = '' }: LeagueCoverProps) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/media/leagues/${leagueId}/cover`}
alt={alt}
className={`w-full h-48 object-cover ${className}`}
onError={(e) => {
// Fallback to default cover
(e.target as HTMLImageElement).src = '/default-league-cover.png';
}}
/>
);
}

View File

@@ -3,14 +3,16 @@
import { useState, useRef, useCallback } from 'react';
import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import {
Move,
RotateCw,
ZoomIn,
ZoomOut,
Save,
Trash2,
Plus,
Image as ImageIcon,
Target
} from 'lucide-react';
@@ -66,7 +68,7 @@ const DEFAULT_PLACEMENTS: Omit<DecalPlacement, 'id'>[] = [
},
];
export default function LeagueDecalPlacementEditor({
export function LeagueDecalPlacementEditor({
leagueId,
seasonId,
carId,
@@ -170,38 +172,47 @@ export default function LeagueDecalPlacementEditor({
};
return (
<div className="space-y-6">
<Stack gap={6}>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-white">{carName}</h3>
<p className="text-sm text-gray-400">Position sponsor decals on this car's template</p>
</div>
<div className="flex items-center gap-2">
<Box display="flex" alignItems="center" justifyContent="between">
<Box>
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">{carName}</Heading>
<Text size="sm" color="text-gray-400">Position sponsor decals on this car&apos;s template</Text>
</Box>
<Box display="flex" alignItems="center" gap={2}>
<Button
variant="secondary"
onClick={() => setZoom(z => Math.max(0.5, z - 0.25))}
disabled={zoom <= 0.5}
>
<ZoomOut className="w-4 h-4" />
<Icon icon={ZoomOut} size={4} />
</Button>
<span className="text-sm text-gray-400 min-w-[3rem] text-center">{Math.round(zoom * 100)}%</span>
<Text size="sm" color="text-gray-400"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ minWidth: '3rem' }}
textAlign="center"
>
{Math.round(zoom * 100)}%
</Text>
<Button
variant="secondary"
onClick={() => setZoom(z => Math.min(2, z + 0.25))}
disabled={zoom >= 2}
>
<ZoomIn className="w-4 h-4" />
<Icon icon={ZoomIn} size={4} />
</Button>
</div>
</div>
</Box>
</Box>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Box display="grid" responsiveGridCols={{ base: 1, lg: 3 }} gap={6}>
{/* Canvas */}
<div className="lg:col-span-2">
<div
<Box responsiveColSpan={{ lg: 2 }}>
<Box
ref={canvasRef}
className="relative aspect-video bg-deep-graphite rounded-lg border border-charcoal-outline overflow-hidden cursor-crosshair"
position="relative"
// eslint-disable-next-line gridpilot-rules/component-classification
className="aspect-video bg-deep-graphite rounded-lg border border-charcoal-outline overflow-hidden cursor-crosshair"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ transform: `scale(${zoom})`, transformOrigin: 'top left' }}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
@@ -209,33 +220,50 @@ export default function LeagueDecalPlacementEditor({
>
{/* Base Image or Placeholder */}
{baseImageUrl ? (
<img
<Box
as="img"
src={baseImageUrl}
alt="Livery template"
className="w-full h-full object-cover"
fullWidth
fullHeight
// eslint-disable-next-line gridpilot-rules/component-classification
className="object-cover"
draggable={false}
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center">
<ImageIcon className="w-16 h-16 text-gray-600 mb-2" />
<p className="text-sm text-gray-500">No base template uploaded</p>
<p className="text-xs text-gray-600">Upload a template image first</p>
</div>
<Box fullWidth fullHeight display="flex" flexDirection="col" alignItems="center" justifyContent="center">
<Icon icon={ImageIcon} size={16} color="text-gray-600"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mb-2"
/>
<Text size="sm" color="text-gray-500">No base template uploaded</Text>
<Text size="xs" color="text-gray-600">Upload a template image first</Text>
</Box>
)}
{/* Decal Placeholders */}
{placements.map((placement) => {
const colors = getSponsorTypeColor(placement.sponsorType);
const decalColors = getSponsorTypeColor(placement.sponsorType);
return (
<div
<Box
key={placement.id}
onMouseDown={(e) => handleMouseDown(e, placement.id)}
onMouseDown={(e: React.MouseEvent) => handleMouseDown(e, placement.id)}
onClick={() => handleDecalClick(placement.id)}
className={`absolute cursor-move border-2 rounded flex items-center justify-center text-xs font-medium transition-all ${
position="absolute"
cursor="move"
border
borderWidth="2px"
rounded="sm"
display="flex"
alignItems="center"
justifyContent="center"
// eslint-disable-next-line gridpilot-rules/component-classification
className={`text-xs font-medium transition-all ${
selectedDecal === placement.id
? `${colors.border} ${colors.bg} ${colors.text} shadow-lg`
: `${colors.border} ${colors.bg} ${colors.text} opacity-70 hover:opacity-100`
? `${decalColors.border} ${decalColors.bg} ${decalColors.text} shadow-lg`
: `${decalColors.border} ${decalColors.bg} ${decalColors.text} opacity-70 hover:opacity-100`
}`}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{
left: `${placement.x * 100}%`,
top: `${placement.y * 100}%`,
@@ -244,151 +272,200 @@ export default function LeagueDecalPlacementEditor({
transform: `translate(-50%, -50%) rotate(${placement.rotation}deg)`,
}}
>
<div className="text-center truncate px-1">
<div className="text-[10px] uppercase tracking-wide opacity-70">
<Box textAlign="center" truncate px={1}>
<Box
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide opacity-70"
>
{placement.sponsorType === 'main' ? 'Main' : 'Secondary'}
</div>
<div className="truncate">{placement.sponsorName}</div>
</div>
</Box>
<Box truncate>{placement.sponsorName}</Box>
</Box>
{/* Drag handle indicator */}
{selectedDecal === placement.id && (
<div className="absolute -top-1 -left-1 w-3 h-3 bg-white rounded-full border-2 border-primary-blue" />
<Box position="absolute" top="-1" left="-1" w="3" h="3" bg="bg-white" rounded="full" border borderWidth="2px" borderColor="border-primary-blue" />
)}
</div>
</Box>
);
})}
{/* Grid overlay when dragging */}
{isDragging && (
<div className="absolute inset-0 pointer-events-none">
<div className="w-full h-full" style={{
backgroundImage: 'linear-gradient(to right, rgba(255,255,255,0.05) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.05) 1px, transparent 1px)',
backgroundSize: '10% 10%',
}} />
</div>
<Box position="absolute" inset="0" pointerEvents="none">
<Box fullWidth fullHeight
// eslint-disable-next-line gridpilot-rules/component-classification
style={{
backgroundImage: 'linear-gradient(to right, rgba(255,255,255,0.05) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.05) 1px, transparent 1px)',
backgroundSize: '10% 10%',
}}
/>
</Box>
)}
</div>
</Box>
<p className="text-xs text-gray-500 mt-2">
<Text size="xs" color="text-gray-500" mt={2} block>
Click a decal to select it, then drag to reposition. Use controls on the right to adjust size and rotation.
</p>
</div>
</Text>
</Box>
{/* Controls Panel */}
<div className="space-y-4">
<Stack gap={4}>
{/* Decal List */}
<Card className="p-4">
<h4 className="text-sm font-semibold text-white mb-3">Sponsor Slots</h4>
<div className="space-y-2">
<Card p={4}>
<Heading level={4} fontSize="sm" weight="semibold" color="text-white" mb={3}>Sponsor Slots</Heading>
<Stack gap={2}>
{placements.map((placement) => {
const colors = getSponsorTypeColor(placement.sponsorType);
const decalColors = getSponsorTypeColor(placement.sponsorType);
return (
<button
<Box
key={placement.id}
as="button"
onClick={() => setSelectedDecal(placement.id)}
className={`w-full p-3 rounded-lg border text-left transition-all ${
selectedDecal === placement.id
? `${colors.border} ${colors.bg}`
: 'border-charcoal-outline bg-iron-gray/30 hover:bg-iron-gray/50'
}`}
w="full"
p={3}
rounded="lg"
border
textAlign="left"
transition
borderColor={selectedDecal === placement.id ? decalColors.border : 'border-charcoal-outline'}
bg={selectedDecal === placement.id ? decalColors.bg : 'bg-iron-gray/30'}
hoverBg={selectedDecal !== placement.id ? 'bg-iron-gray/50' : undefined}
>
<div className="flex items-center justify-between">
<div>
<div className={`text-xs font-medium uppercase ${colors.text}`}>
<Box display="flex" alignItems="center" justifyContent="between">
<Box>
<Box
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="medium"
transform="uppercase"
color={decalColors.text}
>
{placement.sponsorType === 'main' ? 'Main Sponsor' : `Secondary ${placement.sponsorType.split('-')[1]}`}
</div>
<div className="text-xs text-gray-500 mt-0.5">
</Box>
<Box
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
mt={0.5}
>
{Math.round(placement.x * 100)}%, {Math.round(placement.y * 100)}% {placement.rotation}°
</div>
</div>
<Target className={`w-4 h-4 ${selectedDecal === placement.id ? colors.text : 'text-gray-500'}`} />
</div>
</button>
</Box>
</Box>
<Icon icon={Target} size={4} color={selectedDecal === placement.id ? decalColors.text : 'text-gray-500'} />
</Box>
</Box>
);
})}
</div>
</Stack>
</Card>
{/* Selected Decal Controls */}
{selectedPlacement && (
<Card className="p-4">
<h4 className="text-sm font-semibold text-white mb-3">Adjust Selected</h4>
<Card p={4}>
<Heading level={4} fontSize="sm" weight="semibold" color="text-white" mb={3}>Adjust Selected</Heading>
{/* Position */}
<div className="mb-4">
<label className="block text-xs text-gray-400 mb-2">Position</label>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-gray-500 mb-1">X</label>
<input
<Box mb={4}>
<Text as="label" size="xs" color="text-gray-400" block mb={2}>Position</Text>
<Box display="grid" gridCols={2} gap={2}>
<Box>
<Text as="label" size="xs" color="text-gray-500" block mb={1}>X</Text>
<Box
as="input"
type="range"
min="0"
max="100"
value={selectedPlacement.x * 100}
onChange={(e) => updatePlacement(selectedPlacement.id, { x: parseInt(e.target.value) / 100 })}
className="w-full h-2 bg-charcoal-outline rounded-lg appearance-none cursor-pointer accent-primary-blue"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updatePlacement(selectedPlacement.id, { x: parseInt(e.target.value) / 100 })}
fullWidth
h="2"
bg="bg-charcoal-outline"
rounded="lg"
// eslint-disable-next-line gridpilot-rules/component-classification
className="appearance-none cursor-pointer accent-primary-blue"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Y</label>
<input
</Box>
<Box>
<Text as="label" size="xs" color="text-gray-500" block mb={1}>Y</Text>
<Box
as="input"
type="range"
min="0"
max="100"
value={selectedPlacement.y * 100}
onChange={(e) => updatePlacement(selectedPlacement.id, { y: parseInt(e.target.value) / 100 })}
className="w-full h-2 bg-charcoal-outline rounded-lg appearance-none cursor-pointer accent-primary-blue"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updatePlacement(selectedPlacement.id, { y: parseInt(e.target.value) / 100 })}
fullWidth
h="2"
bg="bg-charcoal-outline"
rounded="lg"
// eslint-disable-next-line gridpilot-rules/component-classification
className="appearance-none cursor-pointer accent-primary-blue"
/>
</div>
</div>
</div>
</Box>
</Box>
</Box>
{/* Size */}
<div className="mb-4">
<label className="block text-xs text-gray-400 mb-2">Size</label>
<div className="flex gap-2">
<Box mb={4}>
<Text as="label" size="xs" color="text-gray-400" block mb={2}>Size</Text>
<Box display="flex" gap={2}>
<Button
variant="secondary"
onClick={() => handleResize(selectedPlacement.id, 0.9)}
className="flex-1"
fullWidth
>
<ZoomOut className="w-4 h-4 mr-1" />
<Icon icon={ZoomOut} size={4}
// eslint-disable-next-line gridpilot-rules/component-classification
className="mr-1"
/>
Smaller
</Button>
<Button
variant="secondary"
onClick={() => handleResize(selectedPlacement.id, 1.1)}
className="flex-1"
fullWidth
>
<ZoomIn className="w-4 h-4 mr-1" />
<Icon icon={ZoomIn} size={4}
// eslint-disable-next-line gridpilot-rules/component-classification
className="mr-1"
/>
Larger
</Button>
</div>
</div>
</Box>
</Box>
{/* Rotation */}
<div className="mb-4">
<label className="block text-xs text-gray-400 mb-2">Rotation: {selectedPlacement.rotation}°</label>
<div className="flex items-center gap-2">
<input
<Box mb={4}>
<Text as="label" size="xs" color="text-gray-400" block mb={2}>Rotation: {selectedPlacement.rotation}°</Text>
<Box display="flex" alignItems="center" gap={2}>
<Box
as="input"
type="range"
min="0"
max="360"
step="15"
value={selectedPlacement.rotation}
onChange={(e) => updatePlacement(selectedPlacement.id, { rotation: parseInt(e.target.value) })}
className="flex-1 h-2 bg-charcoal-outline rounded-lg appearance-none cursor-pointer accent-primary-blue"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updatePlacement(selectedPlacement.id, { rotation: parseInt(e.target.value) })}
flexGrow={1}
h="2"
bg="bg-charcoal-outline"
rounded="lg"
// eslint-disable-next-line gridpilot-rules/component-classification
className="appearance-none cursor-pointer accent-primary-blue"
/>
<Button
variant="secondary"
onClick={() => handleRotate(selectedPlacement.id, 90)}
className="px-2"
px={2}
>
<RotateCw className="w-4 h-4" />
<Icon icon={RotateCw} size={4} />
</Button>
</div>
</div>
</Box>
</Box>
</Card>
)}
@@ -397,21 +474,24 @@ export default function LeagueDecalPlacementEditor({
variant="primary"
onClick={handleSave}
disabled={saving}
className="w-full"
fullWidth
>
<Save className="w-4 h-4 mr-2" />
<Icon icon={Save} size={4}
// eslint-disable-next-line gridpilot-rules/component-classification
className="mr-2"
/>
{saving ? 'Saving...' : 'Save Placements'}
</Button>
{/* Help Text */}
<div className="p-3 rounded-lg bg-iron-gray/30 border border-charcoal-outline/50">
<p className="text-xs text-gray-500">
<strong className="text-gray-400">Tip:</strong> Main sponsor gets the largest, most prominent placement.
<Box p={3} rounded="lg" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline/50">
<Text size="xs" color="text-gray-500" block>
<Text weight="bold" color="text-gray-400">Tip:</Text> Main sponsor gets the largest, most prominent placement.
Secondary sponsors get smaller positions. These decals will be burned onto all driver liveries.
</p>
</div>
</div>
</div>
</div>
</Text>
</Box>
</Stack>
</Box>
</Stack>
);
}

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>
);
}
}

View File

@@ -1,9 +1,9 @@
'use client';
import MembershipStatus from '@/components/leagues/MembershipStatus';
import React from 'react';
import { MembershipStatus } from './MembershipStatus';
import { getMediaUrl } from '@/lib/utilities/media';
import Image from 'next/image';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { LeagueHeader as UiLeagueHeader } from '@/ui/LeagueHeader';
// Main sponsor info for "by XYZ" display
interface MainSponsorInfo {
@@ -21,61 +21,39 @@ export interface LeagueHeaderProps {
mainSponsor?: MainSponsorInfo | null;
}
export default function LeagueHeader({
export function LeagueHeader({
leagueId,
leagueName,
description,
ownerId,
mainSponsor,
}: LeagueHeaderProps) {
const logoUrl = getMediaUrl('league-logo', leagueId);
return (
<div className="mb-8">
{/* League header with logo - no cover image */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-xl overflow-hidden border-2 border-charcoal-outline bg-iron-gray shadow-lg">
<img
src={logoUrl}
alt={`${leagueName} logo`}
width={64}
height={64}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
</div>
<div>
<div className="flex items-center gap-3 mb-1">
<h1 className="text-2xl font-bold text-white">
{leagueName}
{mainSponsor && (
<span className="text-gray-400 font-normal text-lg ml-2">
by{' '}
{mainSponsor.websiteUrl ? (
<a
href={mainSponsor.websiteUrl}
target="_blank"
rel="noreferrer"
className="text-primary-blue hover:text-primary-blue/80 transition-colors"
>
{mainSponsor.name}
</a>
) : (
<span className="text-primary-blue">{mainSponsor.name}</span>
)}
</span>
)}
</h1>
<MembershipStatus leagueId={leagueId} />
</div>
{description && (
<p className="text-gray-400 text-sm max-w-xl">{description}</p>
)}
</div>
</div>
</div>
</div>
<UiLeagueHeader
name={leagueName}
description={description}
logoUrl={logoUrl}
statusContent={<MembershipStatus leagueId={leagueId} />}
sponsorContent={
mainSponsor ? (
mainSponsor.websiteUrl ? (
<Box
as="a"
href={mainSponsor.websiteUrl}
target="_blank"
rel="noreferrer"
color="text-primary-blue"
hoverTextColor="text-primary-blue/80"
transition
>
{mainSponsor.name}
</Box>
) : (
<Text color="text-primary-blue">{mainSponsor.name}</Text>
)
) : undefined
}
/>
);
}

View File

@@ -1,30 +0,0 @@
/**
* LeagueLogo
*
* Pure UI component for displaying league logos.
* Renders an optimized image with fallback on error.
*/
import Image from 'next/image';
export interface LeagueLogoProps {
leagueId: string;
alt: string;
className?: string;
}
export function LeagueLogo({ leagueId, alt, className = '' }: LeagueLogoProps) {
return (
<Image
src={`/media/leagues/${leagueId}/logo`}
alt={alt}
width={100}
height={100}
className={`object-contain ${className}`}
onError={(e) => {
// Fallback to default logo
(e.target as HTMLImageElement).src = '/default-league-logo.png';
}}
/>
);
}

View File

@@ -1,15 +1,20 @@
'use client';
import { DriverIdentity } from '../drivers/DriverIdentity';
import { useEffectiveDriverId } from '@/lib/hooks/useEffectiveDriverId';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN, DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { routes } from '@/lib/routing/RouteConfig';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import type { MembershipRole } from '@/lib/types/MembershipRole';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { useCallback, useEffect, useState } from 'react';
// Migrated to useInject-based DI; legacy EntityMapper removed.
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Select } from '@/ui/Select';
import { Button } from '@/ui/Button';
import { LeagueMemberTable } from '@/ui/LeagueMemberTable';
import { LeagueMemberRow } from '@/ui/LeagueMemberRow';
import { MinimalEmptyState } from '@/ui/EmptyState';
interface LeagueMembersProps {
leagueId: string;
@@ -18,7 +23,7 @@ interface LeagueMembersProps {
showActions?: boolean;
}
export default function LeagueMembers({
export function LeagueMembers({
leagueId,
onRemoveMember,
onUpdateRole,
@@ -44,7 +49,8 @@ export default function LeagueMembers({
const driverDtos = await driverService.findByIds(uniqueDriverIds);
const byId: Record<string, DriverViewModel> = {};
for (const dto of driverDtos) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const dto of driverDtos as any[]) {
byId[dto.id] = new DriverViewModel({ ...dto, avatarUrl: dto.avatarUrl ?? null });
}
setDriversById(byId);
@@ -72,12 +78,11 @@ export default function LeagueMembers({
return order[role];
};
const getDriverStats = (driverId: string): { rating: number; wins: number; overallRank: number } | null => {
// This would typically come from a driver stats service
// For now, return null as the original implementation was missing
const getDriverStats = (): { rating: number; wins: number; overallRank: number } | null => {
return null;
};
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
const sortedMembers = [...members].sort((a, b) => {
switch (sortBy) {
case 'role':
@@ -87,15 +92,15 @@ export default function LeagueMembers({
case 'date':
return new Date(b.joinedAt).getTime() - new Date(a.joinedAt).getTime();
case 'rating': {
const statsA = getDriverStats(a.driverId);
const statsB = getDriverStats(b.driverId);
const statsA = getDriverStats();
const statsB = getDriverStats();
return (statsB?.rating || 0) - (statsA?.rating || 0);
}
case 'points':
return 0;
case 'wins': {
const statsA = getDriverStats(a.driverId);
const statsB = getDriverStats(b.driverId);
const statsA = getDriverStats();
const statsB = getDriverStats();
return (statsB?.wins || 0) - (statsA?.wins || 0);
}
default:
@@ -103,180 +108,120 @@ export default function LeagueMembers({
}
});
const getRoleBadgeColor = (role: MembershipRole): string => {
const getRoleVariant = (role: MembershipRole): 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info' => {
switch (role) {
case 'owner':
return 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30';
case 'admin':
return 'bg-purple-500/10 text-purple-400 border-purple-500/30';
case 'steward':
return 'bg-blue-500/10 text-blue-400 border-blue-500/30';
case 'member':
return 'bg-primary-blue/10 text-primary-blue border-primary-blue/30';
default:
return 'bg-gray-500/10 text-gray-400 border-gray-500/30';
case 'owner': return 'warning';
case 'admin': return 'primary';
case 'steward': return 'info';
case 'member': return 'primary';
default: return 'default';
}
};
if (loading) {
return (
<div className="text-center py-8 text-gray-400">
Loading members...
</div>
<Box textAlign="center" py={8}>
<Text color="text-gray-400">Loading members...</Text>
</Box>
);
}
if (members.length === 0) {
return (
<div className="text-center py-8 text-gray-400">
No members found
</div>
<MinimalEmptyState
title="No members found"
description="This league doesn't have any members yet."
/>
);
}
return (
<div>
<Box>
{/* Sort Controls */}
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-gray-400">
<Box display="flex" alignItems="center" justifyContent="between" mb={4}>
<Text size="sm" color="text-gray-400">
{members.length} {members.length === 1 ? 'member' : 'members'}
</p>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-400">Sort by:</label>
<select
</Text>
<Box display="flex" alignItems="center" gap={2}>
<Text as="label" size="sm" color="text-gray-400">Sort by:</Text>
<Select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
>
<option value="rating">Rating</option>
<option value="points">Points</option>
<option value="wins">Wins</option>
<option value="role">Role</option>
<option value="name">Name</option>
<option value="date">Join Date</option>
</select>
</div>
</div>
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setSortBy(e.target.value as typeof sortBy)}
options={[
{ value: 'rating', label: 'Rating' },
{ value: 'points', label: 'Points' },
{ value: 'wins', label: 'Wins' },
{ value: 'role', label: 'Role' },
{ value: 'name', label: 'Name' },
{ value: 'date', label: 'Join Date' },
]}
fullWidth={false}
/>
</Box>
</Box>
{/* Members Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Driver</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Rating</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Rank</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Wins</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Role</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Joined</th>
{showActions && <th className="text-right py-3 px-4 text-sm font-semibold text-gray-400">Actions</th>}
</tr>
</thead>
<tbody>
{sortedMembers.map((member, index) => {
const isCurrentUser = member.driverId === currentDriverId;
const cannotModify = member.role === 'owner';
const driverStats = getDriverStats(member.driverId);
const isTopPerformer = index < 3 && sortBy === 'rating';
const driver = driversById[member.driverId];
const roleLabel =
member.role.charAt(0).toUpperCase() + member.role.slice(1);
const ratingAndWinsMeta =
driverStats && typeof driverStats.rating === 'number'
? `Rating ${driverStats.rating}${driverStats.wins ?? 0} wins`
: null;
<Box overflow="auto">
<LeagueMemberTable showActions={showActions}>
{sortedMembers.map((member, index) => {
const isCurrentUser = member.driverId === currentDriverId;
const cannotModify = member.role === 'owner';
const driverStats = getDriverStats();
const isTopPerformer = index < 3 && sortBy === 'rating';
const driver = driversById[member.driverId];
const ratingAndWinsMeta =
driverStats && typeof driverStats.rating === 'number'
? `Rating ${driverStats.rating}${driverStats.wins ?? 0} wins`
: null;
return (
<tr
key={member.driverId}
className={`border-b border-charcoal-outline/50 hover:bg-iron-gray/20 transition-colors ${isTopPerformer ? 'bg-primary-blue/5' : ''}`}
>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
{driver ? (
<DriverIdentity
driver={driver}
href={`/drivers/${member.driverId}?from=league-members&leagueId=${leagueId}`}
contextLabel={roleLabel}
meta={ratingAndWinsMeta}
size="md"
/>
) : (
<span className="text-white">Unknown Driver</span>
)}
{isCurrentUser && (
<span className="text-xs text-gray-500">(You)</span>
)}
{isTopPerformer && (
<span className="text-xs"></span>
)}
</div>
</td>
<td className="py-3 px-4">
<span className="text-primary-blue font-medium">
{driverStats?.rating || '—'}
</span>
</td>
<td className="py-3 px-4">
<span className="text-gray-300">
#{driverStats?.overallRank || '—'}
</span>
</td>
<td className="py-3 px-4">
<span className="text-green-400 font-medium">
{driverStats?.wins || 0}
</span>
</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 text-xs font-medium rounded border ${getRoleBadgeColor(member.role)}`}>
{member.role.charAt(0).toUpperCase() + member.role.slice(1)}
</span>
</td>
<td className="py-3 px-4">
<span className="text-white text-sm">
{new Date(member.joinedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
</td>
{showActions && (
<td className="py-3 px-4 text-right">
{!cannotModify && !isCurrentUser && (
<div className="flex items-center justify-end gap-2">
{onUpdateRole && (
<select
value={member.role}
onChange={(e) => onUpdateRole(member.driverId, e.target.value as MembershipRole)}
className="px-2 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-xs focus:outline-none focus:ring-2 focus:ring-primary-blue"
>
<option value="member">Member</option>
<option value="steward">Steward</option>
<option value="admin">Admin</option>
</select>
)}
{onRemoveMember && (
<button
onClick={() => onRemoveMember(member.driverId)}
className="px-2 py-1 text-xs font-medium text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded transition-colors"
>
Remove
</button>
)}
</div>
)}
{cannotModify && (
<span className="text-xs text-gray-500"></span>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
return (
<LeagueMemberRow
key={member.driverId}
driver={driver}
driverId={member.driverId}
isCurrentUser={isCurrentUser}
isTopPerformer={isTopPerformer}
role={member.role}
roleVariant={getRoleVariant(member.role)}
joinedAt={member.joinedAt}
rating={driverStats?.rating}
rank={driverStats?.overallRank}
wins={driverStats?.wins}
href={routes.driver.detail(member.driverId)}
meta={ratingAndWinsMeta}
actions={showActions && !cannotModify && !isCurrentUser ? (
<Box display="flex" alignItems="center" justifyContent="end" gap={2}>
{onUpdateRole && (
<Select
value={member.role}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => onUpdateRole(member.driverId, e.target.value as MembershipRole)}
options={[
{ value: 'member', label: 'Member' },
{ value: 'steward', label: 'Steward' },
{ value: 'admin', label: 'Admin' },
]}
fullWidth={false}
// eslint-disable-next-line gridpilot-rules/component-classification
className="text-xs py-1 px-2"
/>
)}
{onRemoveMember && (
<Button
variant="ghost"
onClick={() => onRemoveMember(member.driverId)}
size="sm"
color="text-error-red"
>
Remove
</Button>
)}
</Box>
) : (showActions && cannotModify ? <Text size="xs" color="text-gray-500"></Text> : undefined)}
/>
);
})}
</LeagueMemberTable>
</Box>
</Box>
);
}
}

View File

@@ -3,6 +3,11 @@
import { useState } from 'react';
import { Button } from '@/ui/Button';
import { Input } from '@/ui/Input';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { DollarSign, Calendar, User, TrendingUp } from 'lucide-react';
type FeeType = 'season' | 'monthly' | 'per_race';
@@ -20,8 +25,6 @@ interface LeagueMembershipFeesSectionProps {
}
export function LeagueMembershipFeesSection({
leagueId,
seasonId,
readOnly = false
}: LeagueMembershipFeesSectionProps) {
const [feeConfig, setFeeConfig] = useState<MembershipFeeConfig>({
@@ -71,15 +74,15 @@ export function LeagueMembershipFeesSection({
};
return (
<div className="space-y-6">
<Stack gap={6}>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-white">Membership Fees</h3>
<p className="text-sm text-gray-400 mt-1">
<Box display="flex" alignItems="center" justifyContent="between">
<Box>
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">Membership Fees</Heading>
<Text size="sm" color="text-gray-400" mt={1} block>
Charge drivers for league participation
</p>
</div>
</Text>
</Box>
{!feeConfig.enabled && !readOnly && (
<Button
variant="primary"
@@ -88,152 +91,153 @@ export function LeagueMembershipFeesSection({
Enable Fees
</Button>
)}
</div>
</Box>
{!feeConfig.enabled ? (
<div className="text-center py-12 rounded-lg border border-charcoal-outline bg-iron-gray/30">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
<DollarSign className="w-8 h-8 text-gray-500" />
</div>
<h4 className="text-lg font-medium text-white mb-2">No Membership Fees</h4>
<p className="text-sm text-gray-400 max-w-md mx-auto">
<Box textAlign="center" py={12} rounded="lg" border borderColor="border-charcoal-outline" bg="bg-iron-gray/30">
<Box w="16" h="16" mx="auto" mb={4} rounded="full" bg="bg-iron-gray/50" display="flex" alignItems="center" justifyContent="center">
<Icon icon={DollarSign} size={8} color="text-gray-500" />
</Box>
<Heading level={4} fontSize="lg" weight="medium" color="text-white" mb={2}>No Membership Fees</Heading>
<Text size="sm" color="text-gray-400" maxWidth="md" mx="auto" block>
This league is free to join. Enable membership fees to charge drivers for participation.
</p>
</div>
</Text>
</Box>
) : (
<>
{/* Fee Type Selection */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-300">
<Stack gap={3}>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block>
Fee Type
</label>
<div className="grid grid-cols-3 gap-3">
</Text>
<Box display="grid" gridCols={3} gap={3}>
{(['season', 'monthly', 'per_race'] as FeeType[]).map((type) => {
const Icon = type === 'season' ? Calendar : type === 'monthly' ? TrendingUp : User;
const FeeIcon = type === 'season' ? Calendar : type === 'monthly' ? TrendingUp : User;
const isSelected = feeConfig.type === type;
return (
<button
<Box
key={type}
as="button"
type="button"
onClick={() => handleTypeChange(type)}
disabled={readOnly}
className={`p-4 rounded-lg border transition-all ${
isSelected
? 'border-primary-blue bg-primary-blue/10'
: 'border-charcoal-outline bg-iron-gray/30 hover:border-primary-blue/50'
}`}
p={4}
rounded="lg"
border
transition
borderColor={isSelected ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={isSelected ? 'bg-primary-blue/10' : 'bg-iron-gray/30'}
hoverBorderColor={!isSelected ? 'border-primary-blue/50' : undefined}
>
<Icon className={`w-5 h-5 mx-auto mb-2 ${
isSelected ? 'text-primary-blue' : 'text-gray-400'
}`} />
<div className="text-sm font-medium text-white mb-1">
<Icon icon={FeeIcon} size={5} mx="auto" mb={2} color={isSelected ? 'text-primary-blue' : 'text-gray-400'} />
<Text size="sm" weight="medium" color="text-white" block mb={1}>
{typeLabels[type]}
</div>
<div className="text-xs text-gray-500">
</Text>
<Text size="xs" color="text-gray-500" block>
{typeDescriptions[type]}
</div>
</button>
</Text>
</Box>
);
})}
</div>
</div>
</Box>
</Stack>
{/* Amount Configuration */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-300">
<Stack gap={3}>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block>
Amount
</label>
</Text>
{editing ? (
<div className="flex items-center gap-3">
<div className="flex-1">
<Box display="flex" alignItems="center" gap={3}>
<Box flexGrow={1}>
<Input
type="number"
value={tempAmount}
onChange={(e) => setTempAmount(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTempAmount(e.target.value)}
placeholder="0.00"
min="0"
step="0.01"
/>
</div>
</Box>
<Button
variant="primary"
onClick={handleSave}
className="px-4"
px={4}
>
Save
</Button>
<Button
variant="secondary"
onClick={handleCancel}
className="px-4"
px={4}
>
Cancel
</Button>
</div>
</Box>
) : (
<div className="flex items-center justify-between p-4 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<div>
<div className="text-2xl font-bold text-white">
<Box display="flex" alignItems="center" justifyContent="between" p={4} rounded="lg" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline">
<Box>
<Text size="2xl" weight="bold" color="text-white" block>
${feeConfig.amount.toFixed(2)}
</div>
<div className="text-xs text-gray-500 mt-1">
</Text>
<Text size="xs" color="text-gray-500" mt={1} block>
{typeLabels[feeConfig.type]}
</div>
</div>
</Text>
</Box>
{!readOnly && (
<Button
variant="secondary"
onClick={() => setEditing(true)}
className="px-4"
px={4}
>
Edit Amount
</Button>
)}
</div>
</Box>
)}
</div>
</Stack>
{/* Revenue Breakdown */}
{feeConfig.amount > 0 && (
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="text-xs text-gray-400 mb-1">Platform Fee (10%)</div>
<div className="text-lg font-bold text-warning-amber">
<Box display="grid" gridCols={2} gap={4}>
<Box rounded="lg" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline" p={4}>
<Text size="xs" color="text-gray-400" block mb={1}>Platform Fee (10%)</Text>
<Text size="lg" weight="bold" color="text-warning-amber" block>
-${platformFee.toFixed(2)}
</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="text-xs text-gray-400 mb-1">Net per Driver</div>
<div className="text-lg font-bold text-performance-green">
</Text>
</Box>
<Box rounded="lg" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline" p={4}>
<Text size="xs" color="text-gray-400" block mb={1}>Net per Driver</Text>
<Text size="lg" weight="bold" color="text-performance-green" block>
${netAmount.toFixed(2)}
</div>
</div>
</div>
</Text>
</Box>
</Box>
)}
{/* Disable Fees */}
{!readOnly && (
<div className="pt-4 border-t border-charcoal-outline">
<Box pt={4} borderTop borderColor="border-charcoal-outline">
<Button
variant="danger"
onClick={() => setFeeConfig({ type: 'season', amount: 0, enabled: false })}
>
Disable Membership Fees
</Button>
</div>
</Box>
)}
</>
)}
{/* Alpha Notice */}
<div className="rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> Membership fee collection is demonstration-only.
<Box rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30" p={4}>
<Text size="xs" color="text-gray-400" block>
<Text weight="bold" color="text-warning-amber">Alpha Note:</Text> Membership fee collection is demonstration-only.
In production, fees are collected via payment gateway and deposited to league wallet (minus platform fee).
</p>
</div>
</div>
</Text>
</Box>
</Stack>
);
}

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { DriverSummaryPill } from '@/components/profile/DriverSummaryPill';
import { DriverSummaryPill } from '@/ui/DriverSummaryPillWrapper';
import { Button } from '@/ui/Button';
import { UserCog } from 'lucide-react';
import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel';

View File

@@ -1,14 +1,11 @@
'use client';
import {
FileText,
Users,
Calendar,
Trophy,
Award,
Rocket,
Eye,
EyeOff,
Gamepad2,
User,
UsersRound,
@@ -16,13 +13,18 @@ import {
Flag,
Zap,
Timer,
TrendingDown,
Check,
Globe,
Medal,
type LucideIcon,
} from 'lucide-react';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
interface LeagueReviewSummaryProps {
form: LeagueConfigFormModel;
@@ -31,89 +33,73 @@ interface LeagueReviewSummaryProps {
// Individual review card component
function ReviewCard({
icon: Icon,
icon,
iconColor = 'text-primary-blue',
bgColor = 'bg-primary-blue/10',
title,
children,
}: {
icon: React.ElementType;
icon: LucideIcon;
iconColor?: string;
bgColor?: string;
title: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 space-y-3">
<div className="flex items-center gap-3">
<div className={`flex h-9 w-9 items-center justify-center rounded-lg ${bgColor}`}>
<Icon className={`w-4 h-4 ${iconColor}`} />
</div>
<h3 className="text-sm font-semibold text-white">{title}</h3>
</div>
{children}
</div>
<Box rounded="xl" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/40" p={4}>
<Stack gap={3}>
<Box display="flex" alignItems="center" gap={3}>
<Box display="flex" h="9" w="9" alignItems="center" justifyContent="center" rounded="lg" bg={bgColor}>
<Icon icon={icon} size={4} color={iconColor} />
</Box>
<Heading level={3} fontSize="sm" weight="semibold" color="text-white">{title}</Heading>
</Box>
{children}
</Stack>
</Box>
);
}
// Info row component for consistent layout
function InfoRow({
icon: Icon,
icon,
label,
value,
valueClass = '',
}: {
icon?: React.ElementType;
icon?: LucideIcon;
label: string;
value: React.ReactNode;
valueClass?: string;
}) {
return (
<div className="flex items-center justify-between py-2 border-b border-charcoal-outline/20 last:border-0">
<div className="flex items-center gap-2 text-xs text-gray-500">
{Icon && <Icon className="w-3.5 h-3.5" />}
<span>{label}</span>
</div>
<div className={`text-sm font-medium text-white ${valueClass}`}>{value}</div>
</div>
);
}
// Badge component for enabled features
function FeatureBadge({
icon: Icon,
label,
enabled,
color = 'primary-blue',
}: {
icon: React.ElementType;
label: string;
enabled: boolean;
color?: string;
}) {
if (!enabled) return null;
return (
<span className={`inline-flex items-center gap-1.5 rounded-full bg-${color}/10 px-3 py-1.5 text-xs font-medium text-${color}`}>
<Icon className="w-3 h-3" />
{label}
</span>
<Box display="flex" alignItems="center" justifyContent="between" py={2} borderBottom borderColor="border-charcoal-outline/20"
// eslint-disable-next-line gridpilot-rules/component-classification
className="last:border-0"
>
<Box display="flex" alignItems="center" gap={2}>
{icon && <Icon icon={icon} size={3.5} color="text-gray-500" />}
<Text size="xs" color="text-gray-500">{label}</Text>
</Box>
<Text size="sm" weight="medium" color="text-white"
// eslint-disable-next-line gridpilot-rules/component-classification
className={valueClass}
>
{value}
</Text>
</Box>
);
}
export default function LeagueReviewSummary({ form, presets }: LeagueReviewSummaryProps) {
export function LeagueReviewSummary({ form, presets }: LeagueReviewSummaryProps) {
const { basics, structure, timings, scoring, championships, dropPolicy, stewarding } = form;
const seasonName = (form as LeagueConfigFormModel & { seasonName?: string }).seasonName;
const modeLabel =
structure.mode === 'solo'
? 'Solo drivers'
: 'Team-based';
const modeDescription =
structure.mode === 'solo'
? 'Individual competition'
: 'Teams with fixed rosters';
const capacityValue = (() => {
if (structure.mode === 'solo') {
return typeof structure.maxDrivers === 'number' ? structure.maxDrivers : '—';
@@ -122,12 +108,12 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
})();
const capacityLabel = structure.mode === 'solo' ? 'drivers' : 'teams';
const formatMinutes = (value: number | undefined) => {
if (typeof value !== 'number' || value <= 0) return '—';
return `${value} min`;
};
const getDropRuleInfo = () => {
if (dropPolicy.strategy === 'none') {
return { emoji: '✓', label: 'All count', description: 'Every race counts' };
@@ -148,31 +134,31 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
}
return { emoji: '✓', label: 'All count', description: 'Every race counts' };
};
const dropRuleInfo = getDropRuleInfo();
const dropRuleInfo = getDropRuleInfo();
const preset = presets.find((p) => p.id === scoring.patternId) ?? null;
const preset = presets.find((p) => p.id === scoring.patternId) ?? null;
const seasonStartLabel =
timings.seasonStartDate
? new Date(timings.seasonStartDate).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
: null;
const seasonStartLabel =
timings.seasonStartDate
? new Date(timings.seasonStartDate).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
: null;
const stewardingLabel = (() => {
switch (stewarding.decisionMode) {
case 'admin_only':
return 'Admin-only decisions';
case 'steward_vote':
return 'Steward panel voting';
default:
return stewarding.decisionMode;
}
})();
const stewardingLabel = (() => {
switch (stewarding.decisionMode) {
case 'admin_only':
return 'Admin-only decisions';
case 'steward_vote':
return 'Steward panel voting';
default:
return stewarding.decisionMode;
}
})();
const getScoringEmoji = () => {
if (!preset) return '🏁';
const name = preset.name.toLowerCase();
@@ -181,14 +167,14 @@ const stewardingLabel = (() => {
if (name.includes('club') || name.includes('casual')) return '🏅';
return '🏁';
};
// Normalize visibility to new terminology
const isRanked = basics.visibility === 'public'; // public = ranked, private/unlisted = unranked
const visibilityLabel = isRanked ? 'Ranked' : 'Unranked';
const visibilityDescription = isRanked
? 'Competitive • Affects ratings'
: 'Casual • Friends only';
// Calculate total weekend duration
const totalWeekendMinutes = (timings.practiceMinutes ?? 0) +
(timings.qualifyingMinutes ?? 0) +
@@ -196,118 +182,138 @@ const stewardingLabel = (() => {
(timings.mainRaceMinutes ?? 0);
return (
<div className="space-y-6">
<Stack gap={6}>
{/* League Summary */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-300">League summary</h3>
<div className="relative rounded-2xl bg-gradient-to-br from-primary-blue/20 via-iron-gray to-iron-gray border border-primary-blue/30 p-6 overflow-hidden">
<Stack gap={3}>
<Heading level={3} fontSize="sm" weight="semibold" color="text-gray-300">League summary</Heading>
<Box position="relative" rounded="2xl" bg="bg-gradient-to-br from-primary-blue/20 via-iron-gray to-iron-gray" border borderColor="border-primary-blue/30" p={6} overflow="hidden">
{/* Background decoration */}
<div className="absolute top-0 right-0 w-32 h-32 bg-primary-blue/10 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-24 h-24 bg-neon-aqua/5 rounded-full blur-2xl" />
<Box position="absolute" top="0" right="0" w="32" h="32" bg="bg-primary-blue/10" rounded="full"
// eslint-disable-next-line gridpilot-rules/component-classification
className="blur-3xl"
/>
<Box position="absolute" bottom="0" left="0" w="24" h="24" bg="bg-neon-aqua/5" rounded="full"
// eslint-disable-next-line gridpilot-rules/component-classification
className="blur-2xl"
/>
<div className="relative flex items-start gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-blue/20 border border-primary-blue/30 shrink-0">
<Rocket className="w-7 h-7 text-primary-blue" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-xl font-bold text-white mb-1 truncate">
<Box position="relative" display="flex" alignItems="start" gap={4}>
<Box display="flex" h="14" w="14" alignItems="center" justifyContent="center" rounded="2xl" bg="bg-primary-blue/20" border borderColor="border-primary-blue/30" flexShrink={0}>
<Icon icon={Rocket} size={7} color="text-primary-blue" />
</Box>
<Box flexGrow={1} minWidth="0">
<Heading level={2} fontSize="xl" weight="bold" color="text-white" mb={1} truncate>
{basics.name || 'Your New League'}
</h2>
<p className="text-sm text-gray-400 mb-3">
</Heading>
<Text size="sm" color="text-gray-400" mb={3} block>
{basics.description || 'Ready to launch your racing series!'}
</p>
<div className="flex flex-wrap items-center gap-3">
</Text>
<Box display="flex" flexWrap="wrap" alignItems="center" gap={3}>
{/* Ranked/Unranked Badge */}
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium ${
isRanked
? 'bg-primary-blue/15 text-primary-blue border border-primary-blue/30'
: 'bg-neon-aqua/15 text-neon-aqua border border-neon-aqua/30'
}`}>
{isRanked ? <Trophy className="w-3 h-3" /> : <Users className="w-3 h-3" />}
<span className="font-semibold">{visibilityLabel}</span>
<span className="text-[10px] opacity-70"> {visibilityDescription}</span>
</span>
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
<Gamepad2 className="w-3 h-3" />
iRacing
</span>
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
{structure.mode === 'solo' ? <User className="w-3 h-3" /> : <UsersRound className="w-3 h-3" />}
{modeLabel}
</span>
</div>
</div>
</div>
</div>
</div>
<Box
as="span"
display="inline-flex"
alignItems="center"
gap={1.5}
rounded="full"
px={3}
py={1.5}
bg={isRanked ? 'bg-primary-blue/15' : 'bg-neon-aqua/15'}
color={isRanked ? 'text-primary-blue' : 'text-neon-aqua'}
border
borderColor={isRanked ? 'border-primary-blue/30' : 'border-neon-aqua/30'}
>
<Icon icon={isRanked ? Trophy : Users} size={3} />
<Text weight="semibold" size="xs">{visibilityLabel}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
opacity={0.7}
>
{visibilityDescription}
</Text>
</Box>
<Box as="span" display="inline-flex" alignItems="center" gap={1.5} rounded="full" bg="bg-charcoal-outline/50" px={3} py={1} color="text-gray-300">
<Icon icon={Gamepad2} size={3} />
<Text size="xs" weight="medium">iRacing</Text>
</Box>
<Box as="span" display="inline-flex" alignItems="center" gap={1.5} rounded="full" bg="bg-charcoal-outline/50" px={3} py={1} color="text-gray-300">
<Icon icon={structure.mode === 'solo' ? User : UsersRound} size={3} />
<Text size="xs" weight="medium">{modeLabel}</Text>
</Box>
</Box>
</Box>
</Box>
</Box>
</Stack>
{/* Season Summary */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-300">First season summary</h3>
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-400">
<span>{seasonName || 'First season of this league'}</span>
<Stack gap={3}>
<Heading level={3} fontSize="sm" weight="semibold" color="text-gray-300">First season summary</Heading>
<Box display="flex" flexWrap="wrap" alignItems="center" gap={2}>
<Text size="xs" color="text-gray-400">{seasonName || 'First season of this league'}</Text>
{seasonStartLabel && (
<>
<span></span>
<span>Starts {seasonStartLabel}</span>
<Text size="xs" color="text-gray-400"></Text>
<Text size="xs" color="text-gray-400">Starts {seasonStartLabel}</Text>
</>
)}
{typeof timings.roundsPlanned === 'number' && (
<>
<span></span>
<span>{timings.roundsPlanned} rounds planned</span>
<Text size="xs" color="text-gray-400"></Text>
<Text size="xs" color="text-gray-400">{timings.roundsPlanned} rounds planned</Text>
</>
)}
<span></span>
<span>Stewarding: {stewardingLabel}</span>
</div>
<Text size="xs" color="text-gray-400"></Text>
<Text size="xs" color="text-gray-400">Stewarding: {stewardingLabel}</Text>
</Box>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Box display="grid" gridCols={{ base: 2, md: 4 }} gap={3}>
{/* Capacity */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10 mx-auto mb-2">
<Users className="w-5 h-5 text-primary-blue" />
</div>
<div className="text-2xl font-bold text-white">{capacityValue}</div>
<div className="text-xs text-gray-500">{capacityLabel}</div>
</div>
<Box rounded="xl" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/40" p={4} textAlign="center">
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="lg" bg="bg-primary-blue/10" mx="auto" mb={2}>
<Icon icon={Users} size={5} color="text-primary-blue" />
</Box>
<Text size="2xl" weight="bold" color="text-white" block>{capacityValue}</Text>
<Text size="xs" color="text-gray-500" block>{capacityLabel}</Text>
</Box>
{/* Rounds */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10 mx-auto mb-2">
<Flag className="w-5 h-5 text-performance-green" />
</div>
<div className="text-2xl font-bold text-white">{timings.roundsPlanned ?? '—'}</div>
<div className="text-xs text-gray-500">rounds</div>
</div>
<Box rounded="xl" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/40" p={4} textAlign="center">
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="lg" bg="bg-performance-green/10" mx="auto" mb={2}>
<Icon icon={Flag} size={5} color="text-performance-green" />
</Box>
<Text size="2xl" weight="bold" color="text-white" block>{timings.roundsPlanned ?? '—'}</Text>
<Text size="xs" color="text-gray-500" block>rounds</Text>
</Box>
{/* Weekend Duration */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/10 mx-auto mb-2">
<Timer className="w-5 h-5 text-warning-amber" />
</div>
<div className="text-2xl font-bold text-white">{totalWeekendMinutes > 0 ? `${totalWeekendMinutes}` : '—'}</div>
<div className="text-xs text-gray-500">min/weekend</div>
</div>
<Box rounded="xl" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/40" p={4} textAlign="center">
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="lg" bg="bg-warning-amber/10" mx="auto" mb={2}>
<Icon icon={Timer} size={5} color="text-warning-amber" />
</Box>
<Text size="2xl" weight="bold" color="text-white" block>{totalWeekendMinutes > 0 ? `${totalWeekendMinutes}` : '—'}</Text>
<Text size="xs" color="text-gray-500" block>min/weekend</Text>
</Box>
{/* Championships */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-aqua/10 mx-auto mb-2">
<Award className="w-5 h-5 text-neon-aqua" />
</div>
<div className="text-2xl font-bold text-white">
<Box rounded="xl" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/40" p={4} textAlign="center">
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="lg" bg="bg-neon-aqua/10" mx="auto" mb={2}>
<Icon icon={Award} size={5} color="text-neon-aqua" />
</Box>
<Text size="2xl" weight="bold" color="text-white" block>
{[championships.enableDriverChampionship, championships.enableTeamChampionship, championships.enableNationsChampionship, championships.enableTrophyChampionship].filter(Boolean).length}
</div>
<div className="text-xs text-gray-500">championships</div>
</div>
</div>
</div>
</Text>
<Text size="xs" color="text-gray-500" block>championships</Text>
</Box>
</Box>
</Stack>
{/* Detail Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Box display="grid" gridCols={{ base: 1, md: 2 }} gap={4}>
{/* Schedule Card */}
<ReviewCard icon={Calendar} title="Race Weekend">
<div className="space-y-1">
<Stack gap={1}>
{timings.practiceMinutes && timings.practiceMinutes > 0 && (
<InfoRow icon={Clock} label="Practice" value={formatMinutes(timings.practiceMinutes)} />
)}
@@ -316,89 +322,98 @@ const stewardingLabel = (() => {
<InfoRow icon={Zap} label="Sprint Race" value={formatMinutes(timings.sprintRaceMinutes)} />
)}
<InfoRow icon={Flag} label="Main Race" value={formatMinutes(timings.mainRaceMinutes)} />
</div>
</Stack>
</ReviewCard>
{/* Scoring Card */}
<ReviewCard icon={Trophy} iconColor="text-warning-amber" bgColor="bg-warning-amber/10" title="Scoring System">
<div className="space-y-3">
<Stack gap={3}>
{/* Scoring Preset */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
<span className="text-2xl">{getScoringEmoji()}</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white">{preset?.name ?? 'Custom'}</div>
<div className="text-xs text-gray-500">{preset?.sessionSummary ?? 'Custom scoring enabled'}</div>
</div>
<Box display="flex" alignItems="center" gap={3} p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<Text size="2xl">{getScoringEmoji()}</Text>
<Box flexGrow={1} minWidth="0">
<Text size="sm" weight="medium" color="text-white" block>{preset?.name ?? 'Custom'}</Text>
<Text size="xs" color="text-gray-500" block>{preset?.sessionSummary ?? 'Custom scoring enabled'}</Text>
</Box>
{scoring.customScoringEnabled && (
<span className="px-2 py-0.5 rounded bg-primary-blue/20 text-[10px] font-medium text-primary-blue">Custom</span>
<Box as="span" px={2} py={0.5} rounded="sm" bg="bg-primary-blue/20">
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="medium"
color="text-primary-blue"
>
Custom
</Text>
</Box>
)}
</div>
</Box>
{/* Drop Rule */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-charcoal-outline/50">
<span className="text-base">{dropRuleInfo.emoji}</span>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white">{dropRuleInfo.label}</div>
<div className="text-xs text-gray-500">{dropRuleInfo.description}</div>
</div>
</div>
</div>
<Box display="flex" alignItems="center" gap={3} p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<Box display="flex" h="8" w="8" alignItems="center" justifyContent="center" rounded="lg" bg="bg-charcoal-outline/50">
<Text size="base">{dropRuleInfo.emoji}</Text>
</Box>
<Box flexGrow={1} minWidth="0">
<Text size="sm" weight="medium" color="text-white" block>{dropRuleInfo.label}</Text>
<Text size="xs" color="text-gray-500" block>{dropRuleInfo.description}</Text>
</Box>
</Box>
</Stack>
</ReviewCard>
</div>
</Box>
{/* Championships Section */}
<ReviewCard icon={Award} iconColor="text-neon-aqua" bgColor="bg-neon-aqua/10" title="Active Championships">
<div className="flex flex-wrap gap-2">
<Box display="flex" flexWrap="wrap" gap={2}>
{championships.enableDriverChampionship && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/20 px-3 py-2 text-xs font-medium text-primary-blue">
<Trophy className="w-3.5 h-3.5" />
Driver Championship
<Check className="w-3 h-3 text-performance-green" />
</span>
<Box as="span" display="inline-flex" alignItems="center" gap={1.5} rounded="lg" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" px={3} py={2}>
<Icon icon={Trophy} size={3.5} color="text-primary-blue" />
<Text size="xs" weight="medium" color="text-primary-blue">Driver Championship</Text>
<Icon icon={Check} size={3} color="text-performance-green" />
</Box>
)}
{championships.enableTeamChampionship && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/20 px-3 py-2 text-xs font-medium text-primary-blue">
<Award className="w-3.5 h-3.5" />
Team Championship
<Check className="w-3 h-3 text-performance-green" />
</span>
<Box as="span" display="inline-flex" alignItems="center" gap={1.5} rounded="lg" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" px={3} py={2}>
<Icon icon={Award} size={3.5} color="text-primary-blue" />
<Text size="xs" weight="medium" color="text-primary-blue">Team Championship</Text>
<Icon icon={Check} size={3} color="text-performance-green" />
</Box>
)}
{championships.enableNationsChampionship && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/20 px-3 py-2 text-xs font-medium text-primary-blue">
<Globe className="w-3.5 h-3.5" />
Nations Cup
<Check className="w-3 h-3 text-performance-green" />
</span>
<Box as="span" display="inline-flex" alignItems="center" gap={1.5} rounded="lg" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" px={3} py={2}>
<Icon icon={Globe} size={3.5} color="text-primary-blue" />
<Text size="xs" weight="medium" color="text-primary-blue">Nations Cup</Text>
<Icon icon={Check} size={3} color="text-performance-green" />
</Box>
)}
{championships.enableTrophyChampionship && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/20 px-3 py-2 text-xs font-medium text-primary-blue">
<Medal className="w-3.5 h-3.5" />
Trophy Championship
<Check className="w-3 h-3 text-performance-green" />
</span>
<Box as="span" display="inline-flex" alignItems="center" gap={1.5} rounded="lg" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" px={3} py={2}>
<Icon icon={Medal} size={3.5} color="text-primary-blue" />
<Text size="xs" weight="medium" color="text-primary-blue">Trophy Championship</Text>
<Icon icon={Check} size={3} color="text-performance-green" />
</Box>
)}
{![championships.enableDriverChampionship, championships.enableTeamChampionship, championships.enableNationsChampionship, championships.enableTrophyChampionship].some(Boolean) && (
<span className="text-sm text-gray-500">No championships enabled</span>
<Text size="sm" color="text-gray-500">No championships enabled</Text>
)}
</div>
</Box>
</ReviewCard>
{/* Ready to launch message */}
<div className="rounded-xl bg-performance-green/5 border border-performance-green/20 p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/20">
<Check className="w-5 h-5 text-performance-green" />
</div>
<div>
<p className="text-sm font-medium text-white">Ready to launch!</p>
<p className="text-xs text-gray-400">
Click "Create League" to launch your racing series. You can modify all settings later.
</p>
</div>
</div>
</div>
</div>
<Box rounded="xl" bg="bg-performance-green/5" border borderColor="border-performance-green/20" p={4}>
<Box display="flex" alignItems="center" gap={3}>
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="lg" bg="bg-performance-green/20">
<Icon icon={Check} size={5} color="text-performance-green" />
</Box>
<Box>
<Text size="sm" weight="medium" color="text-white" block>Ready to launch!</Text>
<Text size="xs" color="text-gray-400" block>
Click &quot;Create League&quot; to launch your racing series. You can modify all settings later.
</Text>
</Box>
</Box>
</Box>
</Stack>
);
}

View File

@@ -1,79 +0,0 @@
import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import LeagueSchedule from './LeagueSchedule';
import { LeagueScheduleViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
const mockPush = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}));
vi.mock('@/hooks/useEffectiveDriverId', () => ({
useEffectiveDriverId: () => 'driver-123',
}));
const mockUseLeagueSchedule = vi.fn();
vi.mock('@/hooks/useLeagueService', () => ({
useLeagueSchedule: (...args: unknown[]) => mockUseLeagueSchedule(...args),
}));
vi.mock('@/hooks/useRaceService', () => ({
useRegisterForRace: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
useWithdrawFromRace: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}));
describe('LeagueSchedule', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));
mockPush.mockReset();
mockUseLeagueSchedule.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it('renders a schedule race (no crash)', () => {
mockUseLeagueSchedule.mockReturnValue({
isLoading: false,
data: new LeagueScheduleViewModel([
{
id: 'race-1',
name: 'Round 1',
scheduledAt: new Date('2025-01-02T20:00:00Z'),
isPast: false,
isUpcoming: true,
status: 'scheduled',
},
]),
});
render(<LeagueSchedule leagueId="league-1" />);
expect(screen.getByText('Round 1')).toBeInTheDocument();
});
it('renders loading state while schedule is loading', () => {
mockUseLeagueSchedule.mockReturnValue({
isLoading: true,
data: undefined,
});
render(<LeagueSchedule leagueId="league-1" />);
expect(screen.getByText('Loading schedule...')).toBeInTheDocument();
});
});

View File

@@ -3,22 +3,25 @@
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { useRegisterForRace } from "@/hooks/race/useRegisterForRace";
import { useWithdrawFromRace } from "@/hooks/race/useWithdrawFromRace";
import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
import { useState } from 'react';
import type { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { EmptyState } from '@/components/shared/state/EmptyState';
import { StateContainer } from '@/ui/StateContainer';
import { useLeagueSchedule } from "@/hooks/league/useLeagueSchedule";
import { Calendar } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Button } from '@/ui/Button';
interface LeagueScheduleProps {
leagueId: string;
onRaceClick?: (raceId: string) => void;
}
export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
const router = useRouter();
export function LeagueSchedule({ leagueId, onRaceClick }: LeagueScheduleProps) {
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming');
const currentDriverId = useEffectiveDriverId();
@@ -28,10 +31,6 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
const registerMutation = useRegisterForRace();
const withdrawMutation = useWithdrawFromRace();
const races = useMemo(() => {
return schedule?.races ?? [];
}, [schedule]);
const handleRegister = async (race: LeagueScheduleRaceViewModel, e: React.MouseEvent) => {
e.stopPropagation();
@@ -62,24 +61,6 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
}
};
const upcomingRaces = races.filter((race) => race.isUpcoming);
const pastRaces = races.filter((race) => race.isPast);
const getDisplayRaces = () => {
switch (filter) {
case 'upcoming':
return upcomingRaces;
case 'past':
return [...pastRaces].reverse();
case 'all':
return [...upcomingRaces, ...[...pastRaces].reverse()];
default:
return races;
}
};
const displayRaces = getDisplayRaces();
return (
<StateContainer
data={schedule}
@@ -106,8 +87,10 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
case 'upcoming':
return upcomingRaces;
case 'past':
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
return [...pastRaces].reverse();
case 'all':
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
return [...upcomingRaces, ...[...pastRaces].reverse()];
default:
return races;
@@ -117,56 +100,47 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
const displayRaces = getDisplayRaces();
return (
<div>
<Stack gap={4}>
{/* Filter Controls */}
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-gray-400">
<Box display="flex" alignItems="center" justifyContent="between">
<Text size="sm" color="text-gray-400">
{displayRaces.length} {displayRaces.length === 1 ? 'race' : 'races'}
</p>
<div className="flex gap-2">
<button
</Text>
<Box display="flex" gap={2}>
<Button
variant={filter === 'upcoming' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setFilter('upcoming')}
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
filter === 'upcoming'
? 'bg-primary-blue text-white'
: 'bg-iron-gray text-gray-400 hover:text-white'
}`}
>
Upcoming ({upcomingRaces.length})
</button>
<button
</Button>
<Button
variant={filter === 'past' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setFilter('past')}
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
filter === 'past'
? 'bg-primary-blue text-white'
: 'bg-iron-gray text-gray-400 hover:text-white'
}`}
>
Past ({pastRaces.length})
</button>
<button
</Button>
<Button
variant={filter === 'all' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setFilter('all')}
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
filter === 'all'
? 'bg-primary-blue text-white'
: 'bg-iron-gray text-gray-400 hover:text-white'
}`}
>
All ({races.length})
</button>
</div>
</div>
</Button>
</Box>
</Box>
{/* Race List */}
{displayRaces.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<p className="mb-2">No {filter} races</p>
<Box textAlign="center" py={8}>
<Text color="text-gray-400" block mb={2}>No {filter} races</Text>
{filter === 'upcoming' && (
<p className="text-sm text-gray-500">Schedule your first race to get started</p>
<Text size="sm" color="text-gray-500" block>Schedule your first race to get started</Text>
)}
</div>
</Box>
) : (
<div className="space-y-3">
<Stack gap={3}>
{displayRaces.map((race) => {
const isPast = race.isPast;
const isUpcoming = race.isUpcoming;
@@ -178,91 +152,103 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
registerMutation.isPending || withdrawMutation.isPending;
return (
<div
<Box
key={race.id}
className={`p-4 rounded-lg border transition-all duration-200 cursor-pointer hover:scale-[1.02] ${
isPast
? 'bg-iron-gray/50 border-charcoal-outline/50 opacity-75'
: 'bg-deep-graphite border-charcoal-outline hover:border-primary-blue'
}`}
onClick={() => router.push(`/races/${race.id}`)}
p={4}
rounded="lg"
border
transition
cursor="pointer"
hoverScale
bg={isPast ? 'bg-iron-gray/50' : 'bg-deep-graphite'}
borderColor={isPast ? 'border-charcoal-outline/50' : 'border-charcoal-outline'}
hoverBorderColor={!isPast ? 'border-primary-blue' : undefined}
opacity={isPast ? 0.75 : 1}
onClick={() => onRaceClick?.(race.id)}
>
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<h3 className="text-white font-medium">{trackLabel}</h3>
<Box display="flex" alignItems="center" justifyContent="between" gap={4}>
<Box flexGrow={1}>
<Box display="flex" alignItems="center" gap={2} mb={1} flexWrap="wrap">
<Heading level={3} fontSize="base" weight="medium" color="text-white">{trackLabel}</Heading>
{isUpcoming && !isRegistered && (
<span className="px-2 py-0.5 text-xs font-medium bg-primary-blue/10 text-primary-blue rounded border border-primary-blue/30">
Upcoming
</span>
<Box as="span" px={2} py={0.5} bg="bg-primary-blue/10" border borderColor="border-primary-blue/30" rounded="sm">
<Text size="xs" weight="medium" color="text-primary-blue">Upcoming</Text>
</Box>
)}
{isUpcoming && isRegistered && (
<span className="px-2 py-0.5 text-xs font-medium bg-green-500/10 text-green-400 rounded border border-green-500/30">
Registered
</span>
<Box as="span" px={2} py={0.5} bg="bg-green-500/10" border borderColor="border-green-500/30" rounded="sm">
<Text size="xs" weight="medium" color="text-green-400"> Registered</Text>
</Box>
)}
{isPast && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-700/50 text-gray-400 rounded border border-gray-600/50">
Completed
</span>
<Box as="span" px={2} py={0.5} bg="bg-gray-700/50" border borderColor="border-gray-600/50" rounded="sm">
<Text size="xs" weight="medium" color="text-gray-400">Completed</Text>
</Box>
)}
</div>
<p className="text-sm text-gray-400">{carLabel}</p>
<div className="flex items-center gap-3 mt-2">
<p className="text-xs text-gray-500 uppercase">{sessionTypeLabel}</p>
</div>
</div>
</Box>
<Text size="sm" color="text-gray-400" block>{carLabel}</Text>
<Box mt={2}>
<Text size="xs" color="text-gray-500" transform="uppercase">{sessionTypeLabel}</Text>
</Box>
</Box>
<div className="flex items-center gap-3">
<div className="text-right">
<p className="text-white font-medium">
<Box display="flex" alignItems="center" gap={3}>
<Box textAlign="right">
<Text color="text-white" weight="medium" block>
{race.scheduledAt.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
<p className="text-sm text-gray-400">
</Text>
<Text size="sm" color="text-gray-400" block>
{race.scheduledAt.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</p>
</Text>
{isPast && race.status === 'completed' && (
<p className="text-xs text-primary-blue mt-1">View Results </p>
<Text size="xs" color="text-primary-blue" mt={1} block>View Results </Text>
)}
</div>
</Box>
{/* Registration Actions */}
{isUpcoming && (
<div onClick={(e) => e.stopPropagation()}>
<Box onClick={(e: React.MouseEvent) => e.stopPropagation()}>
{!isRegistered ? (
<button
<Button
variant="primary"
size="sm"
onClick={(e) => handleRegister(race, e)}
disabled={isProcessing}
className="px-3 py-1.5 text-sm font-medium bg-primary-blue hover:bg-primary-blue/80 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
// eslint-disable-next-line gridpilot-rules/component-classification
className="whitespace-nowrap"
>
{registerMutation.isPending ? 'Registering...' : 'Register'}
</button>
</Button>
) : (
<button
<Button
variant="secondary"
size="sm"
onClick={(e) => handleWithdraw(race, e)}
disabled={isProcessing}
className="px-3 py-1.5 text-sm font-medium bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
color="text-gray-300"
// eslint-disable-next-line gridpilot-rules/component-classification
className="whitespace-nowrap"
>
{withdrawMutation.isPending ? 'Withdrawing...' : 'Withdraw'}
</button>
</Button>
)}
</div>
</Box>
)}
</div>
</div>
</div>
</Box>
</Box>
</Box>
);
})}
</div>
</Stack>
)}
</div>
</Stack>
);
}}
</StateContainer>

View File

@@ -1,14 +1,14 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { Trophy, Award, Check, Zap, Settings, Globe, Medal, Plus, Minus, RotateCcw, HelpCircle, X, LucideIcon } from 'lucide-react';
import { Trophy, Award, Check, Zap, Settings, Globe, Medal, Plus, Minus, RotateCcw, HelpCircle, X } from 'lucide-react';
import { createPortal } from 'react-dom';
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { CustomPointsConfig } from '@/lib/view-models/ScoringConfigurationViewModel';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Grid } from '@/ui/Grid';
import { Surface } from '@/ui/Surface';
@@ -102,16 +102,18 @@ function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutP
ref={flyoutRef}
position="fixed"
zIndex={50}
width="380px"
backgroundColor="iron-gray"
w="380px"
bg="bg-iron-gray"
border
borderColor="charcoal-outline"
borderColor="border-charcoal-outline"
rounded="xl"
// eslint-disable-next-line gridpilot-rules/component-classification
className="max-h-[80vh] overflow-y-auto shadow-2xl animate-fade-in"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ top: position.top, left: position.left }}
>
{/* Header */}
<Box display="flex" align="center" justify="between" padding={4} border borderBottom borderColor="charcoal-outline" position="sticky" top={0} backgroundColor="iron-gray" zIndex={10}>
<Box display="flex" alignItems="center" justifyContent="between" p={4} borderBottom borderColor="border-charcoal-outline" 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>
@@ -120,14 +122,14 @@ function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutP
variant="ghost"
size="sm"
onClick={onClose}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-6 w-6 p-0"
icon={<Icon icon={X} size={4} color="text-gray-400" />}
>
{null}
<Icon icon={X} size={4} color="text-gray-400" />
</Button>
</Box>
{/* Content */}
<Box padding={4}>
<Box p={4}>
{children}
</Box>
</Box>,
@@ -142,10 +144,10 @@ function InfoButton({ onClick, buttonRef }: { onClick: () => void; buttonRef: Re
variant="ghost"
size="sm"
onClick={onClick}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-5 w-5 p-0 rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10"
icon={<Icon icon={HelpCircle} size={3.5} />}
>
{null}
<Icon icon={HelpCircle} size={3.5} />
</Button>
);
}
@@ -164,32 +166,64 @@ function PointsSystemMockup() {
];
return (
<Surface variant="dark" rounded="lg" padding={4}>
<Surface variant="dark" rounded="lg" p={4}>
<Stack gap={3}>
<Box display="flex" align="center" justify="between" className="text-[10px] text-gray-500 uppercase tracking-wide px-1">
<Text>Position</Text>
<Text>Points</Text>
<Box display="flex" alignItems="center" justifyContent="between" px={1}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Position
</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Points
</Text>
</Box>
{positions.map((p) => (
<Stack key={p.pos} direction="row" align="center" gap={3}>
<Box width={8} height={8} rounded="lg" className={p.color} display="flex" center>
<Text size="sm" weight="bold" className={p.pos <= 3 ? 'text-deep-graphite' : 'text-gray-400'}>P{p.pos}</Text>
<Box w="8" h="8" rounded="lg" bg={p.color} display="flex" alignItems="center" justifyContent="center">
<Text size="sm" weight="bold" color={p.pos <= 3 ? 'text-deep-graphite' : 'text-gray-400'}>P{p.pos}</Text>
</Box>
<Box flex={1} height={2} backgroundColor="charcoal-outline" rounded="full" className="overflow-hidden opacity-50">
<Box flexGrow={1} h="2" bg="bg-charcoal-outline" rounded="full" overflow="hidden" opacity={0.5}>
<Box
height="full"
className="bg-gradient-to-r from-primary-blue to-neon-aqua rounded-full"
h="full"
bg="bg-gradient-to-r from-primary-blue to-neon-aqua"
rounded="full"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ width: `${(p.pts / 25) * 100}%` }}
/>
</Box>
<Box width={8} textAlign="right">
<Box w="8" textAlign="right">
<Text size="sm" font="mono" weight="semibold" color="text-white">{p.pts}</Text>
</Box>
</Stack>
))}
<Box display="flex" align="center" justify="center" gap={1} pt={2} className="text-[10px] text-gray-500">
<Text>...</Text>
<Text>down to P10 = 1 point</Text>
<Box display="flex" alignItems="center" justifyContent="center" gap={1} pt={2}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
...
</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
down to P10 = 1 point
</Text>
</Box>
</Stack>
</Surface>
@@ -204,15 +238,22 @@ function BonusPointsMockup() {
];
return (
<Surface variant="dark" rounded="lg" padding={4}>
<Surface variant="dark" rounded="lg" p={4}>
<Stack gap={2}>
{bonuses.map((b, i) => (
<Surface key={i} variant="muted" border rounded="lg" padding={2}>
<Surface key={i} variant="muted" border rounded="lg" p={2}>
<Stack direction="row" align="center" gap={3}>
<Text size="xl">{b.emoji}</Text>
<Box flex={1}>
<Box flexGrow={1}>
<Text size="xs" weight="medium" color="text-white" block>{b.label}</Text>
<Text className="text-[10px] text-gray-500" block>{b.desc}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
{b.desc}
</Text>
</Box>
<Text size="sm" font="mono" weight="semibold" color="text-performance-green">{b.pts}</Text>
</Stack>
@@ -231,80 +272,48 @@ function ChampionshipMockup() {
];
return (
<Surface variant="dark" rounded="lg" padding={4}>
<Box display="flex" align="center" gap={2} mb={3} pb={2} border borderBottom borderColor="charcoal-outline" className="opacity-50">
<Surface variant="dark" rounded="lg" p={4}>
<Box display="flex" alignItems="center" gap={2} mb={3} pb={2} borderBottom borderColor="border-charcoal-outline" opacity={0.5}>
<Icon icon={Trophy} size={4} color="text-yellow-500" />
<Text size="xs" weight="semibold" color="text-white">Driver Championship</Text>
</Box>
<Stack gap={2}>
{standings.map((s) => (
<Stack key={s.pos} direction="row" align="center" gap={2}>
<Box width={6} height={6} rounded="full" display="flex" center className={s.pos === 1 ? 'bg-yellow-500 text-deep-graphite' : 'bg-charcoal-outline text-gray-400'}>
<Text className="text-[10px]" weight="bold">{s.pos}</Text>
<Box w="6" h="6" rounded="full" display="flex" alignItems="center" justifyContent="center" bg={s.pos === 1 ? 'bg-yellow-500' : 'bg-charcoal-outline'} color={s.pos === 1 ? 'text-deep-graphite' : 'text-gray-400'}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="bold"
>
{s.pos}
</Text>
</Box>
<Box flex={1}>
<Text size="xs" color="text-white" className="truncate" block>{s.name}</Text>
<Box flexGrow={1}>
<Text size="xs" color="text-white" truncate block>{s.name}</Text>
</Box>
<Text size="xs" font="mono" weight="semibold" color="text-white">{s.pts}</Text>
{s.delta && (
<Text className="text-[10px] font-mono text-gray-500">{s.delta}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
font="mono"
color="text-gray-500"
>
{s.delta}
</Text>
)}
</Stack>
))}
</Stack>
<Box mt={3} pt={2} border borderTop borderColor="charcoal-outline" className="text-[10px] text-gray-500 opacity-50" textAlign="center">
<Text>Points accumulated across all races</Text>
</Box>
</Surface>
);
}
function DropRulesMockup() {
const results = [
{ round: 'R1', pts: 25, dropped: false },
{ round: 'R2', pts: 18, dropped: false },
{ round: 'R3', pts: 4, dropped: true },
{ round: 'R4', pts: 15, dropped: false },
{ round: 'R5', pts: 12, dropped: false },
{ round: 'R6', pts: 0, dropped: true },
];
const total = results.filter(r => !r.dropped).reduce((sum, r) => sum + r.pts, 0);
const wouldBe = results.reduce((sum, r) => sum + r.pts, 0);
return (
<Surface variant="dark" rounded="lg" padding={4}>
<Box mb={3} pb={2} border borderBottom borderColor="charcoal-outline" className="opacity-50">
<Text size="xs" weight="semibold" color="text-white">Best 4 of 6 Results</Text>
</Box>
<Stack direction="row" gap={1} mb={3}>
{results.map((r, i) => (
<Box
key={i}
flex={1}
padding={2}
rounded="lg"
textAlign="center"
border
borderColor={r.dropped ? 'charcoal-outline' : 'performance-green'}
backgroundColor={r.dropped ? 'transparent' : 'performance-green'}
opacity={r.dropped ? 0.5 : 0.1}
className="transition-all"
>
<Text className="text-[9px] text-gray-500" block>{r.round}</Text>
<Text size="xs" font="mono" weight="semibold" color={r.dropped ? 'text-gray-500' : 'text-white'} className={r.dropped ? 'line-through' : ''} block>
{r.pts}
</Text>
</Box>
))}
</Stack>
<Box display="flex" justify="between" align="center">
<Text size="xs" color="text-gray-500">Total counted:</Text>
<Text font="mono" weight="semibold" color="text-performance-green">{total} pts</Text>
</Box>
<Box display="flex" justify="between" align="center" mt={1} className="text-[10px] text-gray-500">
<Text>Without drops:</Text>
<Text font="mono">{wouldBe} pts</Text>
<Box mt={3} pt={2} borderTop borderColor="border-charcoal-outline" opacity={0.5} textAlign="center">
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
Points accumulated across all races
</Text>
</Box>
</Surface>
);
@@ -418,7 +427,10 @@ export function LeagueScoringSection({
}
return (
<Grid cols={2} gap={6} className="lg:grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)]">
<Grid cols={2} gap={6}
// eslint-disable-next-line gridpilot-rules/component-classification
className="lg:grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)]"
>
{patternPanel}
{championshipsPanel}
</Grid>
@@ -560,10 +572,10 @@ export function ScoringPatternSection({
<Stack gap={5}>
{/* Section header */}
<Stack direction="row" align="center" gap={3}>
<Box width={10} height={10} display="flex" center rounded="xl" backgroundColor="primary-blue" opacity={0.1}>
<Box w="10" h="10" display="flex" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue" opacity={0.1}>
<Icon icon={Trophy} size={5} color="text-primary-blue" />
</Box>
<Box flex={1}>
<Box flexGrow={1}>
<Stack direction="row" align="center" gap={2}>
<Heading level={3}>Points System</Heading>
<InfoButton buttonRef={pointsInfoRef} onClick={() => setShowPointsFlyout(true)} />
@@ -586,16 +598,32 @@ export function ScoringPatternSection({
</Text>
<Box>
<Box mb={2} className="text-[10px] text-gray-500 uppercase tracking-wide">
<Text>Example: F1-Style Points</Text>
<Box mb={2}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Example: F1-Style Points
</Text>
</Box>
<PointsSystemMockup />
</Box>
<Surface variant="muted" border rounded="lg" padding={3}>
<Surface variant="muted" border rounded="lg" p={3}>
<Stack direction="row" align="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-primary-blue" className="mt-0.5" />
<Text className="text-[11px] text-gray-400">
<Icon icon={Zap} size={3.5} color="text-primary-blue"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
color="text-gray-400"
>
<Text weight="medium" color="text-primary-blue">Pro tip:</Text> Sprint formats
award points in both races, typically with reduced points for the sprint.
</Text>
@@ -605,11 +633,14 @@ export function ScoringPatternSection({
</InfoFlyout>
{/* Two-column layout: Presets | Custom */}
<Grid cols={2} gap={4} className="lg:grid-cols-[1fr_auto]">
<Grid cols={2} gap={4}
// eslint-disable-next-line gridpilot-rules/component-classification
className="lg:grid-cols-[1fr_auto]"
>
{/* Preset options */}
<Stack gap={2}>
{presets.length === 0 ? (
<Box padding={4} border borderStyle="dashed" borderColor="charcoal-outline" rounded="lg">
<Box p={4} border borderStyle="dashed" borderColor="border-charcoal-outline" rounded="lg">
<Text size="sm" color="text-gray-400">Loading presets...</Text>
</Box>
) : (
@@ -622,6 +653,7 @@ export function ScoringPatternSection({
variant="ghost"
onClick={() => onChangePatternId?.(preset.id)}
disabled={disabled}
// eslint-disable-next-line gridpilot-rules/component-classification
className={`
w-full flex items-center gap-3 p-3 rounded-xl border-2 text-left transition-all duration-200 h-auto
${isSelected
@@ -632,15 +664,17 @@ export function ScoringPatternSection({
>
{/* Radio indicator */}
<Box
width={5}
height={5}
w="5"
h="5"
display="flex"
center
alignItems="center"
justifyContent="center"
rounded="full"
border
borderColor={isSelected ? 'primary-blue' : 'gray-500'}
backgroundColor={isSelected ? 'primary-blue' : 'transparent'}
className="shrink-0 transition-colors"
borderColor={isSelected ? 'border-primary-blue' : 'border-gray-500'}
bg={isSelected ? 'bg-primary-blue' : 'transparent'}
transition
flexShrink={0}
>
{isSelected && <Icon icon={Check} size={3} color="text-white" />}
</Box>
@@ -649,14 +683,20 @@ export function ScoringPatternSection({
<Text size="xl">{getPresetEmoji(preset)}</Text>
{/* Text */}
<Box flex={1} className="min-w-0">
<Box flexGrow={1}
// eslint-disable-next-line gridpilot-rules/component-classification
className="min-w-0"
>
<Text size="sm" weight="medium" color="text-white" block>{preset.name}</Text>
<Text size="xs" color="text-gray-500" block>{getPresetDescription(preset)}</Text>
</Box>
{/* Bonus badge */}
{preset.bonusSummary && (
<Box className="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded-full bg-charcoal-outline/30 text-[10px] text-gray-400">
<Box
// eslint-disable-next-line gridpilot-rules/component-classification
className="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded-full bg-charcoal-outline/30 text-[10px] text-gray-400"
>
<Icon icon={Zap} size={3} />
<Text>{preset.bonusSummary}</Text>
</Box>
@@ -664,7 +704,7 @@ export function ScoringPatternSection({
{/* Info button */}
<Box
ref={(el: any) => { presetInfoRefs.current[preset.id] = el; }}
ref={(el: HTMLElement | null) => { presetInfoRefs.current[preset.id] = el; }}
role="button"
tabIndex={0}
onClick={(e: React.MouseEvent) => {
@@ -678,7 +718,17 @@ export function ScoringPatternSection({
setActivePresetFlyout(activePresetFlyout === preset.id ? null : preset.id);
}
}}
className="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 shrink-0"
display="flex"
h="6"
w="6"
alignItems="center"
justifyContent="center"
rounded="full"
color="text-gray-500"
hoverTextColor="text-primary-blue"
hoverBg="bg-primary-blue/10"
transition
flexShrink={0}
>
<Icon icon={HelpCircle} size={3.5} />
</Box>
@@ -695,13 +745,28 @@ export function ScoringPatternSection({
<Text size="xs" color="text-gray-400">{presetInfo.description}</Text>
<Stack gap={2}>
<Box className="text-[10px] text-gray-500 uppercase tracking-wide">
<Text>Key Features</Text>
<Box>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Key Features
</Text>
</Box>
<Box as="ul" className="space-y-1.5">
<Box as="ul"
// eslint-disable-next-line gridpilot-rules/component-classification
className="space-y-1.5"
>
{presetInfo.details.map((detail, idx) => (
<Box as="li" key={idx} display="flex" align="start" gap={2}>
<Icon icon={Check} size={3} color="text-performance-green" className="mt-0.5" />
<Box as="li" key={idx} display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3} color="text-performance-green"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text size="xs" color="text-gray-400">{detail}</Text>
</Box>
))}
@@ -709,10 +774,17 @@ export function ScoringPatternSection({
</Stack>
{preset.bonusSummary && (
<Surface variant="muted" border rounded="lg" padding={3}>
<Surface variant="muted" border rounded="lg" p={3}>
<Stack direction="row" align="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-primary-blue" className="mt-0.5" />
<Text className="text-[11px] text-gray-400">
<Icon icon={Zap} size={3.5} color="text-primary-blue"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
color="text-gray-400"
>
<Text weight="medium" color="text-primary-blue">Bonus points:</Text> {preset.bonusSummary}
</Text>
</Stack>
@@ -727,11 +799,15 @@ export function ScoringPatternSection({
</Stack>
{/* Custom scoring option */}
<Box width="full" className="lg:w-48">
<Box w="full"
// eslint-disable-next-line gridpilot-rules/component-classification
className="lg:w-48"
>
<Button
variant="ghost"
onClick={onToggleCustomScoring}
disabled={!onToggleCustomScoring || readOnly}
// eslint-disable-next-line gridpilot-rules/component-classification
className={`
w-full h-full min-h-[100px] flex flex-col items-center justify-center gap-2 p-4 rounded-xl border-2 transition-all duration-200
${isCustom
@@ -741,25 +817,40 @@ export function ScoringPatternSection({
`}
>
<Box
width={10}
height={10}
w="10"
h="10"
display="flex"
center
alignItems="center"
justifyContent="center"
rounded="xl"
backgroundColor={isCustom ? 'primary-blue' : 'charcoal-outline'}
bg={isCustom ? 'bg-primary-blue' : 'bg-charcoal-outline'}
opacity={isCustom ? 0.2 : 0.3}
className="transition-colors"
transition
>
<Icon icon={Settings} size={5} color={isCustom ? 'text-primary-blue' : 'text-gray-500'} />
</Box>
<Box textAlign="center">
<Text size="sm" weight="medium" color={isCustom ? 'text-white' : 'text-gray-400'} block>Custom</Text>
<Text className="text-[10px] text-gray-500" block>Define your own</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
Define your own
</Text>
</Box>
{isCustom && (
<Box display="flex" align="center" gap={1} px={2} py={0.5} rounded="full" backgroundColor="primary-blue" opacity={0.2}>
<Box display="flex" alignItems="center" gap={1} px={2} py={0.5} rounded="full" bg="bg-primary-blue" opacity={0.2}>
<Icon icon={Check} size={2.5} color="text-primary-blue" />
<Text className="text-[10px]" weight="medium" color="text-primary-blue">Active</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="medium"
color="text-primary-blue"
>
Active
</Text>
</Box>
)}
</Button>
@@ -773,10 +864,10 @@ export function ScoringPatternSection({
{/* Custom scoring editor - inline, no placeholder */}
{isCustom && (
<Surface variant="muted" border rounded="xl" padding={4}>
<Surface variant="muted" border rounded="xl" p={4}>
<Stack gap={4}>
{/* Header with reset button */}
<Box display="flex" align="center" justify="between">
<Box display="flex" alignItems="center" justifyContent="between">
<Stack direction="row" align="center" gap={2}>
<Icon icon={Settings} size={4} color="text-primary-blue" />
<Text size="sm" weight="medium" color="text-white">Custom Points Table</Text>
@@ -797,16 +888,32 @@ export function ScoringPatternSection({
</Text>
<Box>
<Box mb={2} className="text-[10px] text-gray-500 uppercase tracking-wide">
<Text>Available Bonuses</Text>
<Box mb={2}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Available Bonuses
</Text>
</Box>
<BonusPointsMockup />
</Box>
<Surface variant="muted" border rounded="lg" padding={3}>
<Surface variant="muted" border rounded="lg" p={3}>
<Stack direction="row" align="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-primary-blue" className="mt-0.5" />
<Text className="text-[11px] text-gray-400">
<Icon icon={Zap} size={3.5} color="text-primary-blue"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
color="text-gray-400"
>
<Text weight="medium" color="text-primary-blue">Example:</Text> A driver finishing
P1 with pole and fastest lap would earn 25 + 1 + 1 = 27 points.
</Text>
@@ -820,16 +927,19 @@ export function ScoringPatternSection({
size="sm"
onClick={resetToDefaults}
disabled={readOnly}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-auto py-1 px-2 text-[10px] text-gray-400 hover:text-primary-blue hover:bg-primary-blue/10"
icon={<Icon icon={RotateCcw} size={3} />}
>
Reset
<Stack direction="row" align="center" gap={1}>
<Icon icon={RotateCcw} size={3} />
<Text>Reset</Text>
</Stack>
</Button>
</Box>
{/* Race position points */}
<Stack gap={2}>
<Box display="flex" align="center" justify="between">
<Box display="flex" alignItems="center" justifyContent="between">
<Text size="xs" color="text-gray-400">Finish position points</Text>
<Stack direction="row" align="center" gap={1}>
<Button
@@ -837,54 +947,70 @@ export function ScoringPatternSection({
size="sm"
onClick={removePosition}
disabled={readOnly || customPoints.racePoints.length <= 3}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-5 w-5 p-0"
icon={<Icon icon={Minus} size={3} />}
>
{null}
<Icon icon={Minus} size={3} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={addPosition}
disabled={readOnly || customPoints.racePoints.length >= 20}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-5 w-5 p-0"
icon={<Icon icon={Plus} size={3} />}
>
{null}
<Icon icon={Plus} size={3} />
</Button>
</Stack>
</Box>
<Stack direction="row" wrap gap={1}>
<Box display="flex" flexWrap="wrap" gap={1}>
{customPoints.racePoints.map((pts, idx) => (
<Stack key={idx} align="center">
<Text className="text-[9px] text-gray-500" mb={0.5}>P{idx + 1}</Text>
<Stack direction="row" align="center" gap={0.5}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
color="text-gray-500"
mb={0.5}
>
P{idx + 1}
</Text>
<Box display="flex" alignItems="center">
<Button
variant="secondary"
size="sm"
onClick={() => updateRacePoints(idx, -1)}
disabled={readOnly || pts <= 0}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-5 w-4 p-0 rounded-r-none text-[10px]"
>
</Button>
<Box width={6} height={5} display="flex" center backgroundColor="deep-graphite" border borderTop borderBottom borderColor="charcoal-outline">
<Text className="text-[10px]" weight="medium" color="text-white">{pts}</Text>
<Box w="6" h="5" display="flex" alignItems="center" justifyContent="center" bg="bg-deep-graphite" border borderTop borderBottom borderColor="border-charcoal-outline">
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="medium"
color="text-white"
>
{pts}
</Text>
</Box>
<Button
variant="secondary"
size="sm"
onClick={() => updateRacePoints(idx, 1)}
disabled={readOnly}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-5 w-4 p-0 rounded-l-none text-[10px]"
>
+
</Button>
</Stack>
</Box>
</Stack>
))}
</Stack>
</Box>
</Stack>
{/* Bonus points */}
@@ -895,18 +1021,25 @@ export function ScoringPatternSection({
{ key: 'leaderLapPoints' as const, label: 'Led lap', emoji: '🥇' },
].map((bonus) => (
<Stack key={bonus.key} align="center" gap={1}>
<Text className="text-[10px] text-gray-500">{bonus.emoji} {bonus.label}</Text>
<Stack direction="row" align="center" gap={0.5}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
{bonus.emoji} {bonus.label}
</Text>
<Box display="flex" alignItems="center">
<Button
variant="secondary"
size="sm"
onClick={() => updateBonus(bonus.key, -1)}
disabled={readOnly || customPoints[bonus.key] <= 0}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-6 w-5 p-0 rounded-r-none"
>
</Button>
<Box width={7} height={6} display="flex" center backgroundColor="deep-graphite" border borderTop borderBottom borderColor="charcoal-outline">
<Box w="7" h="6" display="flex" alignItems="center" justifyContent="center" bg="bg-deep-graphite" border borderTop borderBottom borderColor="border-charcoal-outline">
<Text size="xs" weight="medium" color="text-white">{customPoints[bonus.key]}</Text>
</Box>
<Button
@@ -914,11 +1047,12 @@ export function ScoringPatternSection({
size="sm"
onClick={() => updateBonus(bonus.key, 1)}
disabled={readOnly}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-6 w-5 p-0 rounded-l-none"
>
+
</Button>
</Stack>
</Box>
</Stack>
))}
</Grid>
@@ -1040,10 +1174,10 @@ export function ChampionshipsSection({
<Stack gap={4}>
{/* Section header */}
<Stack direction="row" align="center" gap={3}>
<Box width={10} height={10} display="flex" center rounded="xl" backgroundColor="primary-blue" opacity={0.1}>
<Box w="10" h="10" display="flex" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue" opacity={0.1}>
<Icon icon={Award} size={5} color="text-primary-blue" />
</Box>
<Box flex={1}>
<Box flexGrow={1}>
<Stack direction="row" align="center" gap={2}>
<Heading level={3}>Championships</Heading>
<InfoButton buttonRef={champInfoRef} onClick={() => setShowChampFlyout(true)} />
@@ -1066,15 +1200,33 @@ export function ChampionshipsSection({
</Text>
<Box>
<Box mb={2} className="text-[10px] text-gray-500 uppercase tracking-wide">
<Text>Live Standings Example</Text>
<Box mb={2}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Live Standings Example
</Text>
</Box>
<ChampionshipMockup />
</Box>
<Stack gap={2}>
<Box className="text-[10px] text-gray-500 uppercase tracking-wide">
<Text>Championship Types</Text>
<Box>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Championship Types
</Text>
</Box>
<Grid cols={2} gap={2}>
{[
@@ -1083,12 +1235,27 @@ export function ChampionshipsSection({
{ icon: Globe, label: 'Nations', desc: 'By country' },
{ icon: Medal, label: 'Trophy', desc: 'Special class' },
].map((t, i) => (
<Surface key={i} variant="dark" border rounded="lg" padding={2}>
<Surface key={i} variant="dark" border rounded="lg" p={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={t.icon} size={3.5} color="text-primary-blue" />
<Box>
<Text className="text-[10px]" weight="medium" color="text-white" block>{t.label}</Text>
<Text className="text-[9px] text-gray-500" block>{t.desc}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="medium"
color="text-white"
block
>
{t.label}
</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
color="text-gray-500"
block
>
{t.desc}
</Text>
</Box>
</Stack>
</Surface>
@@ -1110,6 +1277,7 @@ export function ChampionshipsSection({
variant="ghost"
disabled={disabled || !champ.available}
onClick={() => champ.available && updateChampionship(champ.key, !champ.enabled)}
// eslint-disable-next-line gridpilot-rules/component-classification
className={`
w-full flex items-center gap-3 p-3 rounded-xl border-2 text-left transition-all duration-200 h-auto
${isEnabled
@@ -1122,34 +1290,54 @@ export function ChampionshipsSection({
>
{/* Toggle indicator */}
<Box
width={5}
height={5}
w="5"
h="5"
display="flex"
center
alignItems="center"
justifyContent="center"
rounded="md"
backgroundColor={isEnabled ? 'primary-blue' : 'charcoal-outline'}
bg={isEnabled ? 'bg-primary-blue' : 'bg-charcoal-outline'}
opacity={isEnabled ? 1 : 0.5}
className="shrink-0 transition-colors"
transition
flexShrink={0}
>
{isEnabled && <Icon icon={Check} size={3} color="text-white" />}
</Box>
{/* Icon */}
<Icon icon={champ.icon} size={4} color={isEnabled ? 'text-primary-blue' : 'text-gray-500'} className="shrink-0" />
<Icon icon={champ.icon} size={4} color={isEnabled ? 'text-primary-blue' : 'text-gray-500'}
// eslint-disable-next-line gridpilot-rules/component-classification
className="shrink-0"
/>
{/* Text */}
<Box flex={1} className="min-w-0">
<Text className={`text-xs font-medium truncate ${isEnabled ? 'text-white' : 'text-gray-400'}`} block>
<Box flexGrow={1}
// eslint-disable-next-line gridpilot-rules/component-classification
className="min-w-0"
>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
className={`text-xs font-medium truncate ${isEnabled ? 'text-white' : 'text-gray-400'}`}
block
>
{champ.label}
</Text>
{!champ.available && champ.unavailableHint && (
<Text className="text-[10px] text-warning-amber/70" block>{champ.unavailableHint}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-warning-amber"
opacity={0.7}
block
>
{champ.unavailableHint}
</Text>
)}
</Box>
{/* Info button */}
<Box
ref={(el: any) => { champItemRefs.current[champ.key] = el; }}
ref={(el: HTMLElement | null) => { champItemRefs.current[champ.key] = el; }}
role="button"
tabIndex={0}
onClick={(e: React.MouseEvent) => {
@@ -1163,7 +1351,17 @@ export function ChampionshipsSection({
setActiveChampFlyout(activeChampFlyout === champ.key ? null : champ.key);
}
}}
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 shrink-0"
display="flex"
h="5"
w="5"
alignItems="center"
justifyContent="center"
rounded="full"
color="text-gray-500"
hoverTextColor="text-primary-blue"
hoverBg="bg-primary-blue/10"
transition
flexShrink={0}
>
<Icon icon={HelpCircle} size={3} />
</Box>
@@ -1175,19 +1373,34 @@ export function ChampionshipsSection({
isOpen={activeChampFlyout === champ.key}
onClose={() => setActiveChampFlyout(null)}
title={champInfo.title}
anchorRef={{ current: champItemRefs.current[champ.key] ?? champInfoRef.current }}
anchorRef={{ current: (champItemRefs.current[champ.key] as HTMLElement | null) ?? champInfoRef.current }}
>
<Stack gap={4}>
<Text size="xs" color="text-gray-400">{champInfo.description}</Text>
<Stack gap={2}>
<Box className="text-[10px] text-gray-500 uppercase tracking-wide">
<Text>How It Works</Text>
<Box>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
How It Works
</Text>
</Box>
<Box as="ul" className="space-y-1.5">
<Box as="ul"
// eslint-disable-next-line gridpilot-rules/component-classification
className="space-y-1.5"
>
{champInfo.details.map((detail, idx) => (
<Box as="li" key={idx} display="flex" align="start" gap={2}>
<Icon icon={Check} size={3} color="text-performance-green" className="mt-0.5" />
<Box as="li" key={idx} display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3} color="text-performance-green"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text size="xs" color="text-gray-400">{detail}</Text>
</Box>
))}
@@ -1195,10 +1408,20 @@ export function ChampionshipsSection({
</Stack>
{!champ.available && (
<Surface variant="muted" border rounded="lg" padding={3} className="bg-warning-amber/5 border-warning-amber/20">
<Surface variant="muted" border rounded="lg" p={3}
// eslint-disable-next-line gridpilot-rules/component-classification
className="bg-warning-amber/5 border-warning-amber/20"
>
<Stack direction="row" align="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-warning-amber" className="mt-0.5" />
<Text className="text-[11px] text-gray-400">
<Icon icon={Zap} size={3.5} color="text-warning-amber"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
color="text-gray-400"
>
<Text weight="medium" color="text-warning-amber">Note:</Text> {champ.unavailableHint}. Switch to Teams mode to enable this championship.
</Text>
</Stack>

View File

@@ -2,13 +2,20 @@
import { Award, DollarSign, Star, X } from 'lucide-react';
import { useState } from 'react';
import PendingSponsorshipRequests, { type PendingRequestDTO } from '../sponsors/PendingSponsorshipRequests';
import Button from '../ui/Button';
import Input from '../ui/Input';
import { PendingSponsorshipRequests } from '../sponsors/PendingSponsorshipRequests';
import { Button } from '@/ui/Button';
import { Input } from '@/ui/Input';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
import { Badge } from '@/ui/Badge';
import { StatBox } from '@/ui/StatBox';
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
import { useLeagueSeasons } from "@/lib/hooks/league/useLeagueSeasons";
import { useSponsorshipRequests } from "@/lib/hooks/league/useSponsorshipRequests";
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { useLeagueSeasons } from "@/hooks/league/useLeagueSeasons";
import { useSponsorshipRequests } from "@/hooks/league/useSponsorshipRequests";
import { useInject } from '@/lib/di/hooks/useInject';
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
@@ -43,12 +50,13 @@ export function LeagueSponsorshipsSection({
const [tempPrice, setTempPrice] = useState<string>('');
// Load season ID if not provided
const { data: seasons = [], isLoading: seasonsLoading } = useLeagueSeasons(leagueId);
const { data: seasons = [] } = useLeagueSeasons(leagueId);
const activeSeason = seasons.find((s) => s.status === 'active') ?? seasons[0];
const seasonId = propSeasonId || activeSeason?.seasonId;
// Load pending sponsorship requests
const { data: pendingRequests = [], isLoading: requestsLoading, refetch: refetchRequests } = useSponsorshipRequests('season', seasonId || '');
const { data: pendingRequestsData, isLoading: requestsLoading, refetch: refetchRequests } = useSponsorshipRequests('season', seasonId || '');
const pendingRequests = pendingRequestsData?.requests || [];
const handleAcceptRequest = async (requestId: string) => {
if (!currentDriverId) return;
@@ -107,107 +115,111 @@ export function LeagueSponsorshipsSection({
const netRevenue = totalRevenue - platformFee;
const availableSlots = slots.filter(s => !s.isOccupied).length;
const occupiedSlots = slots.filter(s => s.isOccupied).length;
return (
<div className="space-y-6">
<Stack gap={6}>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-white">Sponsorships</h3>
<p className="text-sm text-gray-400 mt-1">
<Box display="flex" alignItems="center" justifyContent="between">
<Box>
<Heading level={3}>Sponsorships</Heading>
<Text size="sm" color="text-gray-400" mt={1} block>
Define pricing for sponsor slots in this league. Sponsors pay per season.
</p>
<p className="text-xs text-gray-500 mt-1">
</Text>
<Text size="xs" color="text-gray-500" mt={1} block>
These sponsors are attached to seasons in this league, so you can change partners from season to season.
</p>
</div>
</Text>
</Box>
{!readOnly && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-blue/10 border border-primary-blue/30">
<DollarSign className="w-4 h-4 text-primary-blue" />
<span className="text-xs font-medium text-primary-blue">
<Box display="flex" alignItems="center" gap={2} px={3} py={1.5} rounded="full" bg="bg-primary-blue/10" border borderColor="border-primary-blue/30">
<Icon icon={DollarSign} size={4} color="var(--primary-blue)" />
<Text size="xs" weight="medium" color="text-primary-blue">
{availableSlots} slot{availableSlots !== 1 ? 's' : ''} available
</span>
</div>
</Text>
</Box>
)}
</div>
</Box>
{/* Revenue Summary */}
{totalRevenue > 0 && (
<div className="grid grid-cols-3 gap-4">
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="text-xs text-gray-400 mb-1">Total Revenue</div>
<div className="text-xl font-bold text-white">
${totalRevenue.toFixed(2)}
</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="text-xs text-gray-400 mb-1">Platform Fee (10%)</div>
<div className="text-xl font-bold text-warning-amber">
-${platformFee.toFixed(2)}
</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="text-xs text-gray-400 mb-1">Net Revenue</div>
<div className="text-xl font-bold text-performance-green">
${netRevenue.toFixed(2)}
</div>
</div>
</div>
<Box display="grid" gridCols={3} gap={4}>
<StatBox
icon={DollarSign}
label="Total Revenue"
value={`$${totalRevenue.toFixed(2)}`}
color="var(--primary-blue)"
/>
<StatBox
icon={DollarSign}
label="Platform Fee (10%)"
value={`-$${platformFee.toFixed(2)}`}
color="var(--warning-amber)"
/>
<StatBox
icon={DollarSign}
label="Net Revenue"
value={`$${netRevenue.toFixed(2)}`}
color="var(--performance-green)"
/>
</Box>
)}
{/* Sponsorship Slots */}
<div className="space-y-3">
<Stack gap={3}>
{slots.map((slot, index) => {
const isEditing = editingIndex === index;
const Icon = slot.tier === 'main' ? Star : Award;
const IconComp = slot.tier === 'main' ? Star : Award;
return (
<div
<Box
key={index}
className="rounded-lg border border-charcoal-outline bg-deep-graphite/70 p-4"
rounded="lg"
border
borderColor="border-charcoal-outline"
bg="bg-deep-graphite/70"
p={4}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${
slot.tier === 'main'
? 'bg-primary-blue/10'
: 'bg-gray-500/10'
}`}>
<Icon className={`w-5 h-5 ${
slot.tier === 'main'
? 'text-primary-blue'
: 'text-gray-400'
}`} />
</div>
<Box display="flex" alignItems="center" justifyContent="between" gap={4}>
<Box display="flex" alignItems="center" gap={3} flexGrow={1}>
<Box
display="flex"
h="10"
w="10"
alignItems="center"
justifyContent="center"
rounded="lg"
bg={slot.tier === 'main' ? 'bg-primary-blue/10' : 'bg-gray-500/10'}
>
<Icon icon={IconComp} size={5} color={slot.tier === 'main' ? 'var(--primary-blue)' : 'var(--gray-400)'} />
</Box>
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-white">
<Box flexGrow={1}>
<Box display="flex" alignItems="center" gap={2}>
<Heading level={4}>
{slot.tier === 'main' ? 'Main Sponsor' : 'Secondary Sponsor'}
</h4>
</Heading>
{slot.isOccupied && (
<span className="px-2 py-0.5 text-xs bg-performance-green/10 text-performance-green border border-performance-green/30 rounded-full">
<Badge variant="success">
Occupied
</span>
</Badge>
)}
</div>
<p className="text-xs text-gray-500 mt-0.5">
</Box>
<Text size="xs" color="text-gray-500" mt={0.5} block>
{slot.tier === 'main'
? 'Big livery slot • League page logo • Name in league title'
: 'Small livery slot • League page logo'}
</p>
</div>
</div>
</Text>
</Box>
</Box>
<div className="flex items-center gap-3">
<Box display="flex" alignItems="center" gap={3}>
{isEditing ? (
<div className="flex items-center gap-2">
<Box display="flex" alignItems="center" gap={2}>
<Input
type="number"
value={tempPrice}
onChange={(e) => setTempPrice(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTempPrice(e.target.value)}
placeholder="Price"
// eslint-disable-next-line gridpilot-rules/component-classification
className="w-32"
min="0"
step="0.01"
@@ -215,47 +227,47 @@ export function LeagueSponsorshipsSection({
<Button
variant="primary"
onClick={() => handleSavePrice(index)}
className="px-3 py-1"
size="sm"
>
Save
</Button>
<Button
variant="secondary"
onClick={handleCancelEdit}
className="px-3 py-1"
size="sm"
>
<X className="w-4 h-4" />
<Icon icon={X} size={4} />
</Button>
</div>
</Box>
) : (
<>
<div className="text-right">
<div className="text-lg font-bold text-white">
<Box textAlign="right">
<Text size="lg" weight="bold" color="text-white" block>
${slot.price.toFixed(2)}
</div>
<div className="text-xs text-gray-500">per season</div>
</div>
</Text>
<Text size="xs" color="text-gray-500" block>per season</Text>
</Box>
{!readOnly && !slot.isOccupied && (
<Button
variant="secondary"
onClick={() => handleEditPrice(index)}
className="px-3 py-1"
size="sm"
>
Edit Price
</Button>
)}
</>
)}
</div>
</div>
</div>
</Box>
</Box>
</Box>
);
})}
</div>
</Stack>
{/* Pending Sponsorship Requests */}
{!readOnly && (pendingRequests.length > 0 || requestsLoading) && (
<div className="mt-8 pt-6 border-t border-charcoal-outline">
<Box mt={8} pt={6} borderTop borderColor="border-charcoal-outline">
<PendingSponsorshipRequests
entityType="season"
entityId={seasonId || ''}
@@ -265,16 +277,16 @@ export function LeagueSponsorshipsSection({
onReject={handleRejectRequest}
isLoading={requestsLoading}
/>
</div>
</Box>
)}
{/* Alpha Notice */}
<div className="rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> Sponsorship management is demonstration-only.
<Box rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30" p={4}>
<Text size="xs" color="text-gray-400" block>
<Text weight="bold" color="text-warning-amber">Alpha Note:</Text> Sponsorship management is demonstration-only.
In production, sponsors can browse leagues, select slots, and complete payment integration.
</p>
</div>
</div>
</Text>
</Box>
</Stack>
);
}

View File

@@ -1,8 +1,15 @@
'use client';
import React from 'react';
import { Scale, Users, Clock, Bell, Shield, Vote, UserCheck, AlertTriangle } from 'lucide-react';
import { Scale, Clock, Bell, Shield, Vote, AlertTriangle } from 'lucide-react';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Select } from '@/ui/Select';
import { Checkbox } from '@/ui/Checkbox';
interface LeagueStewardingSectionProps {
form: LeagueConfigFormModel;
@@ -23,14 +30,14 @@ const decisionModeOptions: DecisionModeOption[] = [
value: 'single_steward',
label: 'Single Steward',
description: 'A single steward/admin makes all penalty decisions',
icon: <Shield className="w-5 h-5" />,
icon: <Icon icon={Shield} size={5} />,
requiresVotes: false,
},
{
value: 'committee_vote',
label: 'Committee Vote',
description: 'A group votes to uphold/dismiss protests',
icon: <Scale className="w-5 h-5" />,
icon: <Icon icon={Scale} size={5} />,
requiresVotes: true,
},
];
@@ -66,305 +73,342 @@ export function LeagueStewardingSection({
const selectedMode = decisionModeOptions.find((m) => m.value === stewarding.decisionMode);
return (
<div className="space-y-8">
<Stack gap={8}>
{/* Decision Mode Selection */}
<div>
<h3 className="text-sm font-semibold text-white mb-1 flex items-center gap-2">
<Scale className="w-4 h-4 text-primary-blue" />
How are protest decisions made?
</h3>
<p className="text-xs text-gray-400 mb-4">
<Box>
<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
</p>
</Text>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<Box display="grid" gridCols={{ base: 1, sm: 2, lg: 3 }} gap={3}>
{decisionModeOptions.map((option) => (
<button
<Box
key={option.value}
as="button"
type="button"
disabled={readOnly}
onClick={() => updateStewarding({ decisionMode: option.value })}
className={`
relative flex flex-col items-start gap-2 p-4 rounded-xl border-2 transition-all text-left
${stewarding.decisionMode === option.value
? 'border-primary-blue bg-primary-blue/5 shadow-[0_0_16px_rgba(25,140,255,0.15)]'
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500'
}
${readOnly ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}
`}
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'}
>
<div
className={`p-2 rounded-lg ${
stewarding.decisionMode === option.value
? 'bg-primary-blue/20 text-primary-blue'
: 'bg-charcoal-outline/50 text-gray-400'
}`}
<Box
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}
</div>
<div>
<p className="text-sm font-medium text-white">{option.label}</p>
<p className="text-xs text-gray-400 mt-0.5">{option.description}</p>
</div>
</Box>
<Box>
<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>
</Box>
{stewarding.decisionMode === option.value && (
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-primary-blue" />
<Box position="absolute" top="2" right="2" w="2" h="2" rounded="full" bg="bg-primary-blue" />
)}
</button>
</Box>
))}
</div>
</div>
</Box>
</Box>
{/* Vote Requirements (conditional) */}
{selectedMode?.requiresVotes && (
<div className="p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline space-y-4">
<h4 className="text-sm font-medium text-white flex items-center gap-2">
<Vote className="w-4 h-4 text-primary-blue" />
Voting Configuration
</h4>
<Box 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>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-400 mb-1.5">
Required votes to uphold
</label>
<select
value={stewarding.requiredVotes ?? 2}
onChange={(e) => updateStewarding({ requiredVotes: parseInt(e.target.value, 10) })}
disabled={readOnly}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:ring-1 focus:ring-primary-blue focus:border-primary-blue"
>
<option value={1}>1 vote</option>
<option value={2}>2 votes</option>
<option value={3}>3 votes (majority of 5)</option>
<option value={4}>4 votes</option>
<option value={5}>5 votes</option>
</select>
</div>
<Box display="grid" gridCols={{ base: 1, sm: 2 }} gap={4}>
<Box>
<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' },
]}
/>
</Box>
<div>
<label className="block text-xs font-medium text-gray-400 mb-1.5">
Voting time limit
</label>
<select
value={stewarding.voteTimeLimit}
onChange={(e) => updateStewarding({ voteTimeLimit: parseInt(e.target.value, 10) })}
disabled={readOnly}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:ring-1 focus:ring-primary-blue focus:border-primary-blue"
>
<option value={24}>24 hours</option>
<option value={48}>48 hours</option>
<option value={72}>72 hours (3 days)</option>
<option value={96}>96 hours (4 days)</option>
<option value={168}>168 hours (7 days)</option>
</select>
</div>
</div>
</div>
<Box>
<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)' },
]}
/>
</Box>
</Box>
</Stack>
</Box>
)}
{/* Defense Settings */}
<div>
<h3 className="text-sm font-semibold text-white mb-1 flex items-center gap-2">
<Shield className="w-4 h-4 text-primary-blue" />
Defense Requirements
</h3>
<p className="text-xs text-gray-400 mb-4">
<Box>
<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?
</p>
</Text>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
<Box display="grid" gridCols={{ base: 1, sm: 2 }} gap={3}>
<Box
as="button"
type="button"
disabled={readOnly}
onClick={() => updateStewarding({ requireDefense: false })}
className={`
flex items-center gap-3 p-4 rounded-xl border-2 transition-all text-left
${!stewarding.requireDefense
? 'border-primary-blue bg-primary-blue/5'
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500'
}
${readOnly ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}
`}
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'}
>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
!stewarding.requireDefense ? 'border-primary-blue' : 'border-gray-500'
}`}>
{!stewarding.requireDefense && <div className="w-2 h-2 rounded-full bg-primary-blue" />}
</div>
<div>
<p className="text-sm font-medium text-white">Defense optional</p>
<p className="text-xs text-gray-400">Proceed without waiting for defense</p>
</div>
</button>
<Box 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 && <Box w="2" h="2" rounded="full" bg="bg-primary-blue" />}
</Box>
<Box>
<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>
</Box>
</Box>
<button
<Box
as="button"
type="button"
disabled={readOnly}
onClick={() => updateStewarding({ requireDefense: true })}
className={`
flex items-center gap-3 p-4 rounded-xl border-2 transition-all text-left
${stewarding.requireDefense
? 'border-primary-blue bg-primary-blue/5'
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500'
}
${readOnly ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}
`}
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'}
>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
stewarding.requireDefense ? 'border-primary-blue' : 'border-gray-500'
}`}>
{stewarding.requireDefense && <div className="w-2 h-2 rounded-full bg-primary-blue" />}
</div>
<div>
<p className="text-sm font-medium text-white">Defense required</p>
<p className="text-xs text-gray-400">Wait for defense before deciding</p>
</div>
</button>
</div>
<Box 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 && <Box w="2" h="2" rounded="full" bg="bg-primary-blue" />}
</Box>
<Box>
<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>
</Box>
</Box>
</Box>
{stewarding.requireDefense && (
<div className="mt-4 p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline">
<label className="block text-xs font-medium text-gray-400 mb-1.5">
<Box 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
</label>
<select
value={stewarding.defenseTimeLimit}
</Text>
<Select
value={stewarding.defenseTimeLimit?.toString()}
onChange={(e) => updateStewarding({ defenseTimeLimit: parseInt(e.target.value, 10) })}
disabled={readOnly}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:ring-1 focus:ring-primary-blue focus:border-primary-blue"
>
<option value={24}>24 hours</option>
<option value={48}>48 hours (2 days)</option>
<option value={72}>72 hours (3 days)</option>
<option value={96}>96 hours (4 days)</option>
</select>
<p className="text-xs text-gray-500 mt-2">
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
</p>
</div>
</Text>
</Box>
)}
</div>
</Box>
{/* Deadlines */}
<div>
<h3 className="text-sm font-semibold text-white mb-1 flex items-center gap-2">
<Clock className="w-4 h-4 text-primary-blue" />
Deadlines
</h3>
<p className="text-xs text-gray-400 mb-4">
<Box>
<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
</p>
</Text>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline">
<label className="block text-xs font-medium text-gray-400 mb-1.5">
<Box display="grid" gridCols={{ base: 1, sm: 2 }} gap={4}>
<Box 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)
</label>
<select
value={stewarding.protestDeadlineHours}
</Text>
<Select
value={stewarding.protestDeadlineHours?.toString()}
onChange={(e) => updateStewarding({ protestDeadlineHours: parseInt(e.target.value, 10) })}
disabled={readOnly}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:ring-1 focus:ring-primary-blue focus:border-primary-blue"
>
<option value={12}>12 hours</option>
<option value={24}>24 hours (1 day)</option>
<option value={48}>48 hours (2 days)</option>
<option value={72}>72 hours (3 days)</option>
<option value={168}>168 hours (7 days)</option>
</select>
<p className="text-xs text-gray-500 mt-2">
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
</p>
</div>
</Text>
</Box>
<div className="p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline">
<label className="block text-xs font-medium text-gray-400 mb-1.5">
<Box 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)
</label>
<select
value={stewarding.stewardingClosesHours}
</Text>
<Select
value={stewarding.stewardingClosesHours?.toString()}
onChange={(e) => updateStewarding({ stewardingClosesHours: parseInt(e.target.value, 10) })}
disabled={readOnly}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:ring-1 focus:ring-primary-blue focus:border-primary-blue"
>
<option value={72}>72 hours (3 days)</option>
<option value={96}>96 hours (4 days)</option>
<option value={168}>168 hours (7 days)</option>
<option value={336}>336 hours (14 days)</option>
</select>
<p className="text-xs text-gray-500 mt-2">
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
</p>
</div>
</div>
</div>
</Text>
</Box>
</Box>
</Box>
{/* Notifications */}
<div>
<h3 className="text-sm font-semibold text-white mb-1 flex items-center gap-2">
<Bell className="w-4 h-4 text-primary-blue" />
Notifications
</h3>
<p className="text-xs text-gray-400 mb-4">
<Box>
<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
</p>
</Text>
<div className="space-y-3">
<label
className={`flex items-center gap-3 p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline cursor-pointer hover:bg-iron-gray/60 transition-colors ${
readOnly ? 'opacity-60 cursor-not-allowed' : ''
}`}
<Stack gap={3}>
<Box
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}
>
<input
type="checkbox"
<Checkbox
label="Notify accused driver"
checked={stewarding.notifyAccusedOnProtest}
onChange={(e) => updateStewarding({ notifyAccusedOnProtest: e.target.checked })}
onChange={(checked) => updateStewarding({ notifyAccusedOnProtest: checked })}
disabled={readOnly}
className="w-4 h-4 rounded border-charcoal-outline bg-deep-graphite text-primary-blue focus:ring-primary-blue focus:ring-offset-0"
/>
<div>
<p className="text-sm font-medium text-white">Notify accused driver</p>
<p className="text-xs text-gray-400">
<Box ml={7} mt={1}>
<Text size="xs" color="text-gray-400" block>
Send notification when a protest is filed against them
</p>
</div>
</label>
</Text>
</Box>
</Box>
<label
className={`flex items-center gap-3 p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline cursor-pointer hover:bg-iron-gray/60 transition-colors ${
readOnly ? 'opacity-60 cursor-not-allowed' : ''
}`}
<Box
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}
>
<input
type="checkbox"
<Checkbox
label="Notify voters"
checked={stewarding.notifyOnVoteRequired}
onChange={(e) => updateStewarding({ notifyOnVoteRequired: e.target.checked })}
onChange={(checked) => updateStewarding({ notifyOnVoteRequired: checked })}
disabled={readOnly}
className="w-4 h-4 rounded border-charcoal-outline bg-deep-graphite text-primary-blue focus:ring-primary-blue focus:ring-offset-0"
/>
<div>
<p className="text-sm font-medium text-white">Notify voters</p>
<p className="text-xs text-gray-400">
<Box ml={7} mt={1}>
<Text size="xs" color="text-gray-400" block>
Send notification to stewards/members when their vote is needed
</p>
</div>
</label>
</div>
</div>
</Text>
</Box>
</Box>
</Stack>
</Box>
{/* Warning about strict settings */}
{stewarding.requireDefense && stewarding.decisionMode !== 'single_steward' && (
<div className="flex items-start gap-3 p-4 rounded-xl bg-warning-amber/10 border border-warning-amber/20">
<AlertTriangle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-warning-amber">Strict settings enabled</p>
<p className="text-xs text-warning-amber/80 mt-1">
<Box 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} />
<Box>
<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.
</p>
</div>
</div>
</Text>
</Box>
</Box>
)}
</div>
</Stack>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,70 +0,0 @@
'use client';
import React from 'react';
import { ArrowRight } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Grid } from '@/ui/Grid';
import { Surface } from '@/ui/Surface';
import { Link } from '@/ui/Link';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
interface LeagueSummaryCardProps {
league: {
id: string;
name: string;
description?: string;
settings: {
maxDrivers: number;
qualifyingFormat: string;
};
};
}
export function LeagueSummaryCard({ league }: LeagueSummaryCardProps) {
return (
<Card p={0} style={{ overflow: 'hidden' }}>
<Box p={4}>
<Stack direction="row" align="center" gap={4} mb={4}>
<Box style={{ width: '3.5rem', height: '3.5rem', borderRadius: '0.75rem', overflow: 'hidden', backgroundColor: '#262626', flexShrink: 0 }}>
<Image src={`/media/league-logo/${league.id}`} alt={league.name} width={56} height={56} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block mb={0.5}>League</Text>
<Heading level={3} style={{ fontSize: '1rem' }}>{league.name}</Heading>
</Box>
</Stack>
{league.description && (
<Text size="sm" color="text-gray-400" block mb={4} style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{league.description}</Text>
)}
<Box mb={4}>
<Grid cols={2} gap={3}>
<Surface variant="dark" rounded="lg" padding={3}>
<Text size="xs" color="text-gray-500" block mb={1}>Max Drivers</Text>
<Text weight="medium" color="text-white">{league.settings.maxDrivers}</Text>
</Surface>
<Surface variant="dark" rounded="lg" padding={3}>
<Text size="xs" color="text-gray-500" block mb={1}>Format</Text>
<Text weight="medium" color="text-white" style={{ textTransform: 'capitalize' }}>{league.settings.qualifyingFormat}</Text>
</Surface>
</Grid>
</Box>
<Box>
<Link href={`/leagues/${league.id}`} variant="ghost">
<Button variant="secondary" fullWidth icon={<Icon icon={ArrowRight} size={4} />}>
View League
</Button>
</Link>
</Box>
</Box>
</Card>
);
}

View File

@@ -4,6 +4,7 @@ import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Link } from '@/ui/Link';
import { Text } from '@/ui/Text';
interface Tab {
label: string;
@@ -17,8 +18,8 @@ interface LeagueTabsProps {
export function LeagueTabs({ tabs }: LeagueTabsProps) {
return (
<Box style={{ borderBottom: '1px solid #262626' }}>
<Stack direction="row" gap={6} style={{ overflowX: 'auto' }}>
<Box borderBottom borderColor="border-charcoal-outline">
<Stack direction="row" gap={6} overflow="auto">
{tabs.map((tab) => (
<Link
key={tab.href}
@@ -26,7 +27,12 @@ export function LeagueTabs({ tabs }: LeagueTabsProps) {
variant="ghost"
>
<Box pb={3} px={1}>
<span style={{ fontWeight: 500, whiteSpace: 'nowrap' }}>{tab.label}</span>
<Text weight="medium"
// eslint-disable-next-line gridpilot-rules/component-classification
className="whitespace-nowrap"
>
{tab.label}
</Text>
</Box>
</Link>
))}

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,11 @@ import { useState, useRef, useEffect } from 'react';
import type * as React from 'react';
import { createPortal } from 'react-dom';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
// Minimum drivers for ranked leagues
const MIN_RANKED_DRIVERS = 10;
@@ -82,28 +87,55 @@ function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutP
if (!isOpen || !mounted) return null;
return createPortal(
<div
<Box
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 }}
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' }}
>
<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
);
}
@@ -155,96 +187,139 @@ export function LeagueVisibilitySection({
};
return (
<div className="space-y-8">
<Stack gap={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">
<Box 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?
</p>
</div>
</Text>
</Box>
{/* League Type Selection */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Box display="grid" responsiveGridCols={{ base: 1, md: 2 }} gap={6}>
{/* Ranked (Public) Option */}
<div className="relative">
<button
<Box position="relative">
<Box
as="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'}`}
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 */}
<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'}`}>
<Box display="flex" alignItems="start" justifyContent="between">
<Stack direction="row" align="center" gap={3}>
<Box
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'} />
</Box>
<Box>
<Text weight="bold" size="xl" color={isRanked ? 'text-white' : 'text-gray-300'} block>
Ranked
</div>
<div className={`text-sm ${isRanked ? 'text-primary-blue' : 'text-gray-500'}`}>
</Text>
<Text size="sm" color={isRanked ? 'text-primary-blue' : 'text-gray-500'} block>
Compete for glory
</div>
</div>
</div>
</Text>
</Box>
</Stack>
{/* 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>
<Box
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" />}
</Box>
</Box>
{/* Emotional tagline */}
<p className={`text-sm ${isRanked ? 'text-gray-300' : 'text-gray-500'}`}>
<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.
</p>
</Text>
{/* 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>
<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 */}
<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">
<Box 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
</span>
</div>
</button>
</Text>
</Box>
</Box>
{/* Info button */}
<button
<Box
as="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"
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"
>
<HelpCircle className="w-4 h-4" />
</button>
</div>
<Icon icon={HelpCircle} size={4} />
</Box>
</Box>
{/* Ranked Info Flyout */}
<InfoFlyout
@@ -253,119 +328,176 @@ export function LeagueVisibilitySection({
title="Ranked Leagues"
anchorRef={rankedInfoRef}
>
<div className="space-y-4">
<p className="text-xs text-gray-400">
<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.
</p>
</Text>
<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>
<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}>
<Box 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>
</Box>
<Box 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>
</Box>
</Stack>
</Stack>
<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>
<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}>
<Box 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>
</Box>
<Box 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>
</Box>
</Stack>
</Stack>
</Stack>
</InfoFlyout>
{/* Unranked (Private) Option */}
<div className="relative">
<button
<Box position="relative">
<Box
as="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'}`}
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 */}
<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'}`}>
<Box display="flex" alignItems="start" justifyContent="between">
<Stack direction="row" align="center" gap={3}>
<Box
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'} />
</Box>
<Box>
<Text weight="bold" size="xl" color={!isRanked ? 'text-white' : 'text-gray-300'} block>
Unranked
</div>
<div className={`text-sm ${!isRanked ? 'text-neon-aqua' : 'text-gray-500'}`}>
</Text>
<Text size="sm" color={!isRanked ? 'text-neon-aqua' : 'text-gray-500'} block>
Race with friends
</div>
</div>
</div>
</Text>
</Box>
</Stack>
{/* 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>
<Box
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" />}
</Box>
</Box>
{/* Emotional tagline */}
<p className={`text-sm ${!isRanked ? 'text-gray-300' : 'text-gray-500'}`}>
<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.
</p>
</Text>
{/* 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>
<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 */}
<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'}`}>
<Box 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
</span>
</div>
</button>
</Text>
</Box>
</Box>
{/* Info button */}
<button
<Box
as="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"
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"
>
<HelpCircle className="w-4 h-4" />
</button>
</div>
<Icon icon={HelpCircle} size={4} />
</Box>
</Box>
{/* Unranked Info Flyout */}
<InfoFlyout
@@ -374,88 +506,103 @@ export function LeagueVisibilitySection({
title="Unranked Leagues"
anchorRef={unrankedInfoRef}
>
<div className="space-y-4">
<p className="text-xs text-gray-400">
<Stack gap={4}>
<Text size="xs" color="text-gray-400" block>
Unranked leagues are casual, private series for racing with friends.
Results don't affect driver ratings, so you can practice and have fun
Results don&apos;t affect driver ratings, so you can practice and have fun
without pressure.
</p>
</Text>
<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>
<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}>
<Box 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>
</Box>
<Box 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>
</Box>
<Box 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>
</Box>
</Stack>
</Stack>
<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>
<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}>
<Box 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>
</Box>
<Box 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>
</Box>
</Stack>
</Stack>
</Stack>
</InfoFlyout>
</div>
</Box>
{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>
<Box 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>
</Box>
)}
{/* 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">
<Box
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'}
>
<Box display="flex" alignItems="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">
<Icon icon={Trophy} size={5} color="text-primary-blue" flexShrink={0} mt={0.5} />
<Box>
<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.
</p>
</div>
</Text>
</Box>
</>
) : (
<>
<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">
<Icon icon={Users} size={5} color="text-neon-aqua" flexShrink={0} mt={0.5} />
<Box>
<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.
</p>
</div>
</Text>
</Box>
</>
)}
</div>
</div>
</div>
</Box>
</Box>
</Stack>
);
}
}

View File

@@ -1,80 +1,63 @@
'use client';
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { getMembership } from '@/lib/leagueMembership';
import type { MembershipRole } from '@/lib/types/MembershipRole';
import { Badge } from '@/ui/Badge';
interface MembershipStatusProps {
leagueId: string;
className?: string;
}
export default function MembershipStatus({ leagueId, className = '' }: MembershipStatusProps) {
export function MembershipStatus({ leagueId }: MembershipStatusProps) {
const currentDriverId = useEffectiveDriverId();
if (!currentDriverId) {
return (
<span className={`px-3 py-1 text-xs font-medium bg-gray-700/50 text-gray-400 rounded border border-gray-600/50 ${className}`}>
<Badge variant="default">
Not a Member
</span>
</Badge>
);
}
const membership = getMembership(leagueId, currentDriverId);
const membership = currentDriverId ? getMembership(leagueId, currentDriverId) : null;
if (!membership) {
return (
<span className={`px-3 py-1 text-xs font-medium bg-gray-700/50 text-gray-400 rounded border border-gray-600/50 ${className}`}>
<Badge variant="default">
Not a Member
</span>
</Badge>
);
}
const getRoleDisplay = (role: MembershipRole): { text: string; bgColor: string; textColor: string; borderColor: string } => {
const getRoleVariant = (role: MembershipRole): 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info' => {
switch (role) {
case 'owner':
return {
text: 'Owner',
bgColor: 'bg-yellow-500/10',
textColor: 'text-yellow-500',
borderColor: 'border-yellow-500/30',
};
return 'warning';
case 'admin':
return {
text: 'Admin',
bgColor: 'bg-purple-500/10',
textColor: 'text-purple-400',
borderColor: 'border-purple-500/30',
};
return 'primary';
case 'steward':
return {
text: 'Steward',
bgColor: 'bg-blue-500/10',
textColor: 'text-blue-400',
borderColor: 'border-blue-500/30',
};
return 'info';
case 'member':
return {
text: 'Member',
bgColor: 'bg-primary-blue/10',
textColor: 'text-primary-blue',
borderColor: 'border-primary-blue/30',
};
return 'primary';
default:
return {
text: 'Member',
bgColor: 'bg-primary-blue/10',
textColor: 'text-primary-blue',
borderColor: 'border-primary-blue/30',
};
return 'default';
}
};
const { text, bgColor, textColor, borderColor } = getRoleDisplay(membership.role);
const getRoleText = (role: MembershipRole): string => {
switch (role) {
case 'owner': return 'Owner';
case 'admin': return 'Admin';
case 'steward': return 'Steward';
case 'member': return 'Member';
default: return 'Member';
}
};
return (
<span className={`px-3 py-1 text-xs font-medium ${bgColor} ${textColor} rounded border ${borderColor} ${className}`}>
{text}
</span>
<Badge variant={getRoleVariant(membership.role)}>
{getRoleText(membership.role)}
</Badge>
);
}

View File

@@ -1,23 +0,0 @@
'use client';
import { Plus } from 'lucide-react';
import Button from '../ui/Button';
interface PenaltyFABProps {
onClick: () => void;
}
export default function PenaltyFAB({ onClick }: PenaltyFABProps) {
return (
<div className="fixed bottom-6 right-6 z-50">
<Button
variant="primary"
className="w-14 h-14 rounded-full shadow-lg"
onClick={onClick}
title="Add Penalty"
>
<Plus className="w-6 h-6" />
</Button>
</div>
);
}

View File

@@ -5,8 +5,12 @@ import { ProtestViewModel } from "@/lib/view-models/ProtestViewModel";
import { RaceViewModel } from "@/lib/view-models/RaceViewModel";
import { DriverViewModel } from "@/lib/view-models/DriverViewModel";
import { Card } from "@/ui/Card";
import { Button } from "@/ui/Button";
import { Clock, Grid3x3, TrendingDown, AlertCircle, Filter, Flag } from "lucide-react";
import { Box } from "@/ui/Box";
import { Stack } from "@/ui/Stack";
import { Text } from "@/ui/Text";
import { Heading } from "@/ui/Heading";
import { Icon } from "@/ui/Icon";
import { AlertCircle, Flag } from "lucide-react";
interface PenaltyHistoryListProps {
protests: ProtestViewModel[];
@@ -20,7 +24,6 @@ export function PenaltyHistoryList({
drivers,
}: PenaltyHistoryListProps) {
const [filteredProtests, setFilteredProtests] = useState<ProtestViewModel[]>([]);
const [filterType, setFilterType] = useState<"all">("all");
useEffect(() => {
setFilteredProtests(protests);
@@ -29,86 +32,108 @@ export function PenaltyHistoryList({
const getStatusColor = (status: string) => {
switch (status) {
case "upheld":
return "text-red-400 bg-red-500/20";
return { text: "text-red-400", bg: "bg-red-500/20" };
case "dismissed":
return "text-gray-400 bg-gray-500/20";
return { text: "text-gray-400", bg: "bg-gray-500/20" };
case "withdrawn":
return "text-blue-400 bg-blue-500/20";
return { text: "text-blue-400", bg: "bg-blue-500/20" };
default:
return "text-orange-400 bg-orange-500/20";
return { text: "text-orange-400", bg: "bg-orange-500/20" };
}
};
return (
<div className="space-y-4">
<Stack gap={4}>
{filteredProtests.length === 0 ? (
<Card className="p-12 text-center">
<div className="flex flex-col items-center gap-4 text-gray-400">
<AlertCircle className="h-12 w-12 opacity-50" />
<div>
<p className="font-medium text-lg">No Resolved Protests</p>
<p className="text-sm mt-1">
<Card py={12} textAlign="center">
<Stack alignItems="center" gap={4}>
<Icon icon={AlertCircle} size={12} color="text-gray-400" opacity={0.5} />
<Box>
<Text weight="medium" size="lg" color="text-gray-400" block>No Resolved Protests</Text>
<Text size="sm" color="text-gray-500" mt={1} block>
No protests have been resolved in this league
</p>
</div>
</div>
</Text>
</Box>
</Stack>
</Card>
) : (
<div className="space-y-3">
<Stack gap={3}>
{filteredProtests.map((protest) => {
const race = races[protest.raceId];
const protester = drivers[protest.protestingDriverId];
const accused = drivers[protest.accusedDriverId];
const incident = protest.incident;
const resolvedDate = protest.reviewedAt || protest.filedAt;
const statusColors = getStatusColor(protest.status);
return (
<Card key={protest.id} className="p-4">
<div className="flex items-start gap-4">
<div className={`h-10 w-10 rounded-full flex items-center justify-center flex-shrink-0 ${getStatusColor(protest.status)}`}>
<Flag className="h-5 w-5" />
</div>
<div className="flex-1 space-y-2">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="font-semibold text-white">
Protest #{protest.id.substring(0, 8)}
</h3>
<p className="text-sm text-gray-400">
{resolvedDate ? `Resolved ${new Date(resolvedDate).toLocaleDateString()}` : 'Resolved'}
</p>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium flex-shrink-0 ${getStatusColor(protest.status)}`}>
{protest.status.toUpperCase()}
</span>
</div>
<div className="space-y-1 text-sm">
<p className="text-gray-400">
<span className="font-medium">{protester?.name || 'Unknown'}</span> vs <span className="font-medium">{accused?.name || 'Unknown'}</span>
</p>
{race && incident && (
<p className="text-gray-500">
{race.track} ({race.car}) - Lap {incident.lap}
</p>
<Card key={protest.id} p={4}>
<Box display="flex" alignItems="start" gap={4}>
<Box
w="10"
h="10"
rounded="full"
display="flex"
alignItems="center"
justifyContent="center"
flexShrink={0}
bg={statusColors.bg}
color={statusColors.text}
>
<Icon icon={Flag} size={5} />
</Box>
<Box flex={1}>
<Stack gap={2}>
<Box display="flex" alignItems="start" justifyContent="between" gap={4}>
<Box>
<Heading level={3}>
Protest #{protest.id.substring(0, 8)}
</Heading>
<Text size="sm" color="text-gray-400" block>
{resolvedDate ? `Resolved ${new Date(resolvedDate).toLocaleDateString()}` : 'Resolved'}
</Text>
</Box>
<Box
px={3}
py={1}
rounded="full"
bg={statusColors.bg}
color={statusColors.text}
fontSize="12px"
weight="medium"
flexShrink={0}
>
{protest.status.toUpperCase()}
</Box>
</Box>
<Box>
<Text size="sm" color="text-gray-400" block>
<Text weight="medium" color="text-white">{protester?.name || 'Unknown'}</Text> vs <Text weight="medium" color="text-white">{accused?.name || 'Unknown'}</Text>
</Text>
{race && incident && (
<Text size="sm" color="text-gray-500" block>
{race.track} ({race.car}) - Lap {incident.lap}
</Text>
)}
</Box>
{incident && (
<Text size="sm" color="text-gray-300" block>{incident.description}</Text>
)}
</div>
{incident && (
<p className="text-gray-300 text-sm">{incident.description}</p>
)}
{protest.decisionNotes && (
<div className="mt-2 p-2 rounded bg-iron-gray/30 border border-charcoal-outline/50">
<p className="text-xs text-gray-400">
<span className="font-medium">Steward Notes:</span> {protest.decisionNotes}
</p>
</div>
)}
</div>
</div>
{protest.decisionNotes && (
<Box mt={2} p={2} rounded="md" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline/50">
<Text size="xs" color="text-gray-400" block>
<Text weight="medium">Steward Notes:</Text> {protest.decisionNotes}
</Text>
</Box>
)}
</Stack>
</Box>
</Box>
</Card>
);
})}
</div>
</Stack>
)}
</div>
</Stack>
);
}

View File

@@ -1,115 +0,0 @@
"use client";
import { ProtestViewModel } from "@/lib/view-models/ProtestViewModel";
import { RaceViewModel } from "@/lib/view-models/RaceViewModel";
import { DriverViewModel } from "@/lib/view-models/DriverViewModel";
import { Card } from "@/ui/Card";
import { Button } from "@/ui/Button";
import Link from "next/link";
import { AlertCircle, Video, ChevronRight, Flag, Clock, AlertTriangle } from "lucide-react";
interface PendingProtestsListProps {
protests: ProtestViewModel[];
races: Record<string, RaceViewModel>;
drivers: Record<string, DriverViewModel>;
leagueId: string;
onReviewProtest: (protest: ProtestViewModel) => void;
onProtestReviewed: () => void;
}
export function PendingProtestsList({
protests,
races,
drivers,
leagueId,
onReviewProtest,
onProtestReviewed,
}: PendingProtestsListProps) {
if (protests.length === 0) {
return (
<Card className="p-12 text-center">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 rounded-full bg-performance-green/10 flex items-center justify-center">
<Flag className="h-8 w-8 text-performance-green" />
</div>
<div>
<p className="font-semibold text-lg text-white mb-2">All Clear! 🏁</p>
<p className="text-sm text-gray-400">No pending protests to review</p>
</div>
</div>
</Card>
);
}
return (
<div className="space-y-4">
{protests.map((protest) => {
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt || protest.submittedAt).getTime()) / (1000 * 60 * 60 * 24));
const isUrgent = daysSinceFiled > 2;
return (
<Card
key={protest.id}
className={`p-6 hover:border-warning-amber/40 transition-all ${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3 flex-wrap">
<div className="h-10 w-10 rounded-full bg-warning-amber/20 flex items-center justify-center flex-shrink-0">
<AlertCircle className="h-5 w-5 text-warning-amber" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-white">
Protest #{protest.id.substring(0, 8)}
</h3>
<p className="text-sm text-gray-400">
Filed {new Date(protest.filedAt || protest.submittedAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-1 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full flex items-center gap-1">
<Clock className="h-3 w-3" />
Pending
</span>
{isUrgent && (
<span className="px-2 py-1 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{daysSinceFiled}d old
</span>
)}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Flag className="h-4 w-4 text-gray-400" />
<span className="text-gray-400">Lap {protest.incident?.lap || 'N/A'}</span>
</div>
<p className="text-gray-300 line-clamp-2 leading-relaxed">
{protest.incident?.description || protest.description}
</p>
{protest.proofVideoUrl && (
<div className="inline-flex items-center gap-2 px-3 py-1.5 text-sm bg-primary-blue/10 text-primary-blue rounded-lg border border-primary-blue/20">
<Video className="h-4 w-4" />
<span>Video evidence attached</span>
</div>
)}
</div>
</div>
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
<Button
variant="secondary"
className="flex-shrink-0"
>
<ChevronRight className="h-5 w-5" />
</Button>
</Link>
</div>
</Card>
);
})}
</div>
);
}

View File

@@ -1,55 +0,0 @@
import React from 'react';
import Card from '@/ui/Card';
interface PointsTableProps {
title?: string;
points: { position: number; points: number }[];
}
export default function PointsTable({ title = 'Points Distribution', points }: PointsTableProps) {
return (
<Card>
<h2 className="text-lg font-semibold text-white mb-4">{title}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 font-medium text-gray-400">Position</th>
<th className="text-right py-3 px-4 font-medium text-gray-400">Points</th>
</tr>
</thead>
<tbody>
{points.map(({ position, points: pts }) => (
<tr
key={position}
className={`border-b border-charcoal-outline/50 transition-colors hover:bg-iron-gray/30 ${
position <= 3 ? 'bg-iron-gray/20' : ''
}`}
>
<td className="py-3 px-4">
<div className="flex items-center gap-3">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold ${
position === 1 ? 'bg-yellow-500 text-black' :
position === 2 ? 'bg-gray-400 text-black' :
position === 3 ? 'bg-amber-600 text-white' :
'bg-charcoal-outline text-white'
}`}>
{position}
</div>
<span className="text-white font-medium">
{position === 1 ? '1st' : position === 2 ? '2nd' : position === 3 ? '3rd' : `${position}th`}
</span>
</div>
</td>
<td className="py-3 px-4 text-right">
<span className="text-white font-semibold tabular-nums">{pts}</span>
<span className="text-gray-500 ml-1">pts</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
}

View File

@@ -1,10 +1,16 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import Button from '@/ui/Button';
import { usePenaltyMutation } from "@/lib/hooks/league/usePenaltyMutation";
import { useState } from 'react';
import { Button } from '@/ui/Button';
import { usePenaltyMutation } from "@/hooks/league/usePenaltyMutation";
import { AlertTriangle, Clock, Flag, Zap } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Select } from '@/ui/Select';
import { TextArea } from '@/ui/TextArea';
interface DriverOption {
id: string;
@@ -15,6 +21,7 @@ interface QuickPenaltyModalProps {
raceId?: string;
drivers: DriverOption[];
onClose: () => void;
onRefresh?: () => void;
preSelectedDriver?: DriverOption;
adminId: string;
races?: { id: string; track: string; scheduledAt: Date }[];
@@ -35,14 +42,13 @@ const SEVERITY_LEVELS = [
{ value: 'severe', label: 'Severe', description: 'Heavy penalty' },
] as const;
export default function QuickPenaltyModal({ raceId, drivers, onClose, preSelectedDriver, adminId, races }: QuickPenaltyModalProps) {
export function QuickPenaltyModal({ raceId, drivers, onClose, onRefresh, preSelectedDriver, adminId, races }: QuickPenaltyModalProps) {
const [selectedRaceId, setSelectedRaceId] = useState<string>(raceId || '');
const [selectedDriver, setSelectedDriver] = useState<string>(preSelectedDriver?.id || '');
const [infractionType, setInfractionType] = useState<string>('');
const [severity, setSeverity] = useState<string>('');
const [notes, setNotes] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const penaltyMutation = usePenaltyMutation();
const handleSubmit = async (e: React.FormEvent) => {
@@ -64,7 +70,7 @@ export default function QuickPenaltyModal({ raceId, drivers, onClose, preSelecte
await penaltyMutation.mutateAsync(command);
// Refresh the page to show updated results
router.refresh();
onRefresh?.();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to apply penalty');
@@ -72,135 +78,148 @@ export default function QuickPenaltyModal({ raceId, drivers, onClose, preSelecte
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="w-full max-w-md bg-iron-gray rounded-xl border border-charcoal-outline shadow-2xl">
<div className="p-6">
<h2 className="text-xl font-bold text-white mb-4">Quick Penalty</h2>
<Box position="fixed" inset="0" zIndex={50} display="flex" alignItems="center" justifyContent="center" p={4} bg="bg-black/70"
// eslint-disable-next-line gridpilot-rules/component-classification
className="backdrop-blur-sm"
>
<Box w="full" maxWidth="md" bg="bg-iron-gray" rounded="xl" border borderColor="border-charcoal-outline" shadow="2xl">
<Box p={6}>
<Heading level={2} fontSize="xl" weight="bold" color="text-white" mb={4}>Quick Penalty</Heading>
<form onSubmit={handleSubmit} className="space-y-4">
<Box as="form" onSubmit={handleSubmit} display="flex" flexDirection="col" gap={4}>
{/* Race Selection */}
{races && !raceId && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
Race
</label>
<select
</Text>
<Select
value={selectedRaceId}
onChange={(e) => setSelectedRaceId(e.target.value)}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:border-primary-blue focus:outline-none"
required
>
<option value="">Select race...</option>
{races.map((race) => (
<option key={race.id} value={race.id}>
{race.track} ({race.scheduledAt.toLocaleDateString()})
</option>
))}
</select>
</div>
options={[
{ value: '', label: 'Select race...' },
...races.map((race) => ({
value: race.id,
label: `${race.track} (${race.scheduledAt.toLocaleDateString()})`,
})),
]}
/>
</Box>
)}
{/* Driver Selection */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
Driver
</label>
</Text>
{preSelectedDriver ? (
<div className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white">
<Box w="full" px={3} py={2} bg="bg-deep-graphite" border borderColor="border-charcoal-outline" rounded="lg" color="text-white">
{preSelectedDriver.name}
</div>
</Box>
) : (
<select
<Select
value={selectedDriver}
onChange={(e) => setSelectedDriver(e.target.value)}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:border-primary-blue focus:outline-none"
required
>
<option value="">Select driver...</option>
{drivers.map((driver) => (
<option key={driver.id} value={driver.id}>
{driver.name}
</option>
))}
</select>
options={[
{ value: '', label: 'Select driver...' },
...drivers.map((driver) => ({
value: driver.id,
label: driver.name,
})),
]}
/>
)}
</div>
</Box>
{/* Infraction Type */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
Infraction Type
</label>
<div className="grid grid-cols-2 gap-2">
{INFRACTION_TYPES.map(({ value, label, icon: Icon }) => (
<button
</Text>
<Box display="grid" gridCols={2} gap={2}>
{INFRACTION_TYPES.map(({ value, label, icon: InfractionIcon }) => (
<Box
key={value}
as="button"
type="button"
onClick={() => setInfractionType(value)}
className={`flex items-center gap-2 p-3 rounded-lg border transition-colors ${
infractionType === value
? 'border-primary-blue bg-primary-blue/10 text-primary-blue'
: 'border-charcoal-outline bg-deep-graphite text-gray-300 hover:border-gray-500'
}`}
display="flex"
alignItems="center"
gap={2}
p={3}
rounded="lg"
border
transition
borderColor={infractionType === value ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={infractionType === value ? 'bg-primary-blue/10' : 'bg-deep-graphite'}
color={infractionType === value ? 'text-primary-blue' : 'text-gray-300'}
hoverBorderColor={infractionType !== value ? 'border-gray-500' : undefined}
>
<Icon className="w-4 h-4" />
<span className="text-sm">{label}</span>
</button>
<Icon icon={InfractionIcon} size={4} />
<Text size="sm">{label}</Text>
</Box>
))}
</div>
</div>
</Box>
</Box>
{/* Severity */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
Severity
</label>
<div className="space-y-2">
</Text>
<Stack gap={2}>
{SEVERITY_LEVELS.map(({ value, label, description }) => (
<button
<Box
key={value}
as="button"
type="button"
onClick={() => setSeverity(value)}
className={`w-full text-left p-3 rounded-lg border transition-colors ${
severity === value
? 'border-primary-blue bg-primary-blue/10 text-primary-blue'
: 'border-charcoal-outline bg-deep-graphite text-gray-300 hover:border-gray-500'
}`}
w="full"
textAlign="left"
p={3}
rounded="lg"
border
transition
borderColor={severity === value ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={severity === value ? 'bg-primary-blue/10' : 'bg-deep-graphite'}
color={severity === value ? 'text-primary-blue' : 'text-gray-300'}
hoverBorderColor={severity !== value ? 'border-gray-500' : undefined}
>
<div className="font-medium">{label}</div>
<div className="text-xs opacity-75">{description}</div>
</button>
<Text weight="medium" block>{label}</Text>
<Text size="xs" opacity={0.75} block>{description}</Text>
</Box>
))}
</div>
</div>
</Stack>
</Box>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
Notes (Optional)
</label>
<textarea
</Text>
<TextArea
value={notes}
onChange={(e) => setNotes(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNotes(e.target.value)}
placeholder="Additional details..."
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:border-primary-blue focus:outline-none resize-none"
rows={3}
/>
</div>
</Box>
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
<p className="text-sm text-red-400">{error}</p>
</div>
<Box p={3} bg="bg-red-500/10" border borderColor="border-red-500/20" rounded="lg">
<Text size="sm" color="text-red-400" block>{error}</Text>
</Box>
)}
{/* Actions */}
<div className="flex gap-3 pt-4">
<Box display="flex" gap={3} pt={4}>
<Button
type="button"
variant="secondary"
onClick={onClose}
className="flex-1"
fullWidth
disabled={penaltyMutation.isPending}
>
Cancel
@@ -208,15 +227,15 @@ export default function QuickPenaltyModal({ raceId, drivers, onClose, preSelecte
<Button
type="submit"
variant="primary"
className="flex-1"
fullWidth
disabled={penaltyMutation.isPending || !selectedRaceId || !selectedDriver || !infractionType || !severity}
>
{penaltyMutation.isPending ? 'Applying...' : 'Apply Penalty'}
</Button>
</div>
</form>
</div>
</div>
</div>
</Box>
</Box>
</Box>
</Box>
</Box>
);
}
}

View File

@@ -2,6 +2,9 @@
import { Calendar, Users, Trophy, Gamepad2, Eye, Hash, Award } from 'lucide-react';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { InfoItem } from '@/ui/InfoItem';
interface ReadonlyLeagueInfoProps {
league: {
@@ -64,27 +67,19 @@ export function ReadonlyLeagueInfo({ league, configForm }: ReadonlyLeagueInfoPro
];
return (
<div className="rounded-xl border border-charcoal-outline bg-gradient-to-br from-iron-gray/40 to-iron-gray/20 p-5">
<h3 className="text-sm font-semibold text-gray-400 mb-4">League Information</h3>
<Box rounded="xl" border borderColor="border-charcoal-outline" bg="bg-iron-gray/40" p={5}>
<Heading level={5} color="text-gray-400" mb={4}>League Information</Heading>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{infoItems.map((item, index) => {
const Icon = item.icon;
return (
<div key={index} className="flex items-start gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-iron-gray/60 shrink-0">
<Icon className="w-3.5 h-3.5 text-gray-500" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[10px] text-gray-500 mb-0.5">{item.label}</div>
<div className="text-xs font-medium text-gray-300 truncate">
{item.value}
</div>
</div>
</div>
);
})}
</div>
</div>
<Box display="grid" responsiveGridCols={{ base: 2, sm: 3 }} gap={4}>
{infoItems.map((item, index) => (
<InfoItem
key={index}
icon={item.icon}
label={item.label}
value={item.value}
/>
))}
</Box>
</Box>
);
}
}

View File

@@ -1,12 +1,19 @@
"use client";
import { useMemo, useState } from "react";
import { usePenaltyTypesReference } from "@/lib/hooks/usePenaltyTypesReference";
import { usePenaltyTypesReference } from "@/hooks/usePenaltyTypesReference";
import type { PenaltyValueKindDTO } from "@/lib/types/PenaltyTypesReferenceDTO";
import { ProtestViewModel } from "../../lib/view-models/ProtestViewModel";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import Card from "../ui/Card";
import { Modal } from "@/ui/Modal";
import { Button } from "@/ui/Button";
import { Card } from "@/ui/Card";
import { Box } from "@/ui/Box";
import { Text } from "@/ui/Text";
import { Stack } from "@/ui/Stack";
import { Heading } from "@/ui/Heading";
import { Icon } from "@/ui/Icon";
import { TextArea } from "@/ui/TextArea";
import { Input } from "@/ui/Input";
import {
AlertCircle,
Video,
@@ -15,12 +22,12 @@ import {
TrendingDown,
CheckCircle,
XCircle,
FileText,
AlertTriangle,
ShieldAlert,
Ban,
DollarSign,
FileWarning,
type LucideIcon,
} from "lucide-react";
type PenaltyType = string;
@@ -50,6 +57,27 @@ export function ReviewProtestModal({
const [submitting, setSubmitting] = useState(false);
const [showConfirmation, setShowConfirmation] = useState(false);
const { data: penaltyTypesReference, isLoading: penaltyTypesLoading } = usePenaltyTypesReference();
const penaltyOptions = useMemo(() => {
const refs = penaltyTypesReference?.penaltyTypes ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return refs.map((ref: any) => ({
type: ref.type as PenaltyType,
name: getPenaltyName(ref.type),
requiresValue: ref.requiresValue,
valueLabel: getPenaltyValueLabel(ref.valueKind),
defaultValue: getPenaltyDefaultValue(ref.type, ref.valueKind),
Icon: getPenaltyIcon(ref.type),
colorClass: getPenaltyColor(ref.type),
}));
}, [penaltyTypesReference]);
const selectedPenalty = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return penaltyOptions.find((p: any) => p.type === penaltyType);
}, [penaltyOptions, penaltyType]);
if (!protest) return null;
const handleSubmit = async () => {
@@ -178,63 +206,46 @@ export function ReviewProtestModal({
}
};
const { data: penaltyTypesReference, isLoading: penaltyTypesLoading } = usePenaltyTypesReference();
const penaltyOptions = useMemo(() => {
const refs = penaltyTypesReference?.penaltyTypes ?? [];
return refs.map((ref) => ({
type: ref.type as PenaltyType,
name: getPenaltyName(ref.type),
requiresValue: ref.requiresValue,
valueLabel: getPenaltyValueLabel(ref.valueKind),
defaultValue: getPenaltyDefaultValue(ref.type, ref.valueKind),
Icon: getPenaltyIcon(ref.type),
colorClass: getPenaltyColor(ref.type),
}));
}, [penaltyTypesReference]);
const selectedPenalty = useMemo(() => {
return penaltyOptions.find((p) => p.type === penaltyType);
}, [penaltyOptions, penaltyType]);
if (showConfirmation) {
return (
<Modal title="Confirm Decision" isOpen={true} onOpenChange={() => setShowConfirmation(false)}>
<div className="p-6 space-y-6">
<div className="text-center space-y-4">
{decision === "accept" ? (
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-orange-500/20 flex items-center justify-center">
<AlertCircle className="h-8 w-8 text-orange-400" />
</div>
</div>
) : (
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-gray-500/20 flex items-center justify-center">
<XCircle className="h-8 w-8 text-gray-400" />
</div>
</div>
)}
<div>
<h3 className="text-xl font-bold text-white">Confirm Decision</h3>
<p className="text-gray-400 mt-2">
{decision === "accept"
? (selectedPenalty?.requiresValue
? `Issue ${penaltyValue} ${selectedPenalty.valueLabel} penalty?`
: `Issue ${selectedPenalty?.name ?? penaltyType} penalty?`)
: "Reject this protest?"}
</p>
</div>
</div>
<Stack gap={6} p={6}>
<Box textAlign="center">
<Stack gap={4}>
{decision === "accept" ? (
<Box display="flex" justifyContent="center">
<Box h="16" w="16" rounded="full" bg="bg-orange-500/20" display="flex" alignItems="center" justifyContent="center">
<Icon icon={AlertCircle} size={8} color="text-orange-400" />
</Box>
</Box>
) : (
<Box display="flex" justifyContent="center">
<Box h="16" w="16" rounded="full" bg="bg-gray-500/20" display="flex" alignItems="center" justifyContent="center">
<Icon icon={XCircle} size={8} color="text-gray-400" />
</Box>
</Box>
)}
<Box>
<Heading level={3} fontSize="xl" weight="bold" color="text-white">Confirm Decision</Heading>
<Text color="text-gray-400" mt={2} block>
{decision === "accept"
? (selectedPenalty?.requiresValue
? `Issue ${penaltyValue} ${selectedPenalty.valueLabel} penalty?`
: `Issue ${selectedPenalty?.name ?? penaltyType} penalty?`)
: "Reject this protest?"}
</Text>
</Box>
</Stack>
</Box>
<Card className="p-4 bg-gray-800/50">
<p className="text-sm text-gray-300">{stewardNotes}</p>
<Card p={4} bg="bg-gray-800/50">
<Text size="sm" color="text-gray-300" block>{stewardNotes}</Text>
</Card>
<div className="flex gap-3">
<Box display="flex" gap={3}>
<Button
variant="secondary"
className="flex-1"
fullWidth
onClick={() => setShowConfirmation(false)}
disabled={submitting}
>
@@ -242,189 +253,207 @@ export function ReviewProtestModal({
</Button>
<Button
variant="primary"
className="flex-1"
fullWidth
onClick={handleSubmit}
disabled={submitting}
>
{submitting ? "Submitting..." : "Confirm Decision"}
</Button>
</div>
</div>
</Box>
</Stack>
</Modal>
);
}
return (
<Modal title="Review Protest" isOpen={true} onOpenChange={onClose}>
<div className="p-6 space-y-6">
<div className="flex items-start gap-4">
<div className="h-12 w-12 rounded-full bg-orange-500/20 flex items-center justify-center flex-shrink-0">
<AlertCircle className="h-6 w-6 text-orange-400" />
</div>
<div className="flex-1">
<h2 className="text-2xl font-bold text-white">Review Protest</h2>
<p className="text-gray-400 mt-1">
<Stack gap={6} p={6}>
<Box display="flex" alignItems="start" gap={4}>
<Box h="12" w="12" rounded="full" bg="bg-orange-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<Icon icon={AlertCircle} size={6} color="text-orange-400" />
</Box>
<Box flexGrow={1}>
<Heading level={2} fontSize="2xl" weight="bold" color="text-white">Review Protest</Heading>
<Text color="text-gray-400" mt={1} block>
Protest #{protest.id.substring(0, 8)}
</p>
</div>
</div>
</Text>
</Box>
</Box>
<div className="space-y-4">
<Card className="p-4 bg-gray-800/50">
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Filed Date</span>
<span className="text-white font-medium">
<Stack gap={4}>
<Card p={4} bg="bg-gray-800/50">
<Stack gap={3}>
<Box display="flex" alignItems="center" justifyContent="between">
<Text size="sm" color="text-gray-400">Filed Date</Text>
<Text size="sm" color="text-white" weight="medium">
{new Date(protest.filedAt || protest.submittedAt).toLocaleString()}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Incident Lap</span>
<span className="text-white font-medium">
</Text>
</Box>
<Box display="flex" alignItems="center" justifyContent="between">
<Text size="sm" color="text-gray-400">Incident Lap</Text>
<Text size="sm" color="text-white" weight="medium">
Lap {protest.incident?.lap || 'N/A'}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Status</span>
<span className="px-2 py-1 rounded text-xs font-medium bg-orange-500/20 text-orange-400">
{protest.status}
</span>
</div>
</div>
</Text>
</Box>
<Box display="flex" alignItems="center" justifyContent="between">
<Text size="sm" color="text-gray-400">Status</Text>
<Box as="span" px={2} py={1} rounded="sm" bg="bg-orange-500/20">
<Text size="xs" weight="medium" color="text-orange-400">
{protest.status}
</Text>
</Box>
</Box>
</Stack>
</Card>
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-400" block mb={2}>
Description
</label>
<Card className="p-4 bg-gray-800/50">
<p className="text-gray-300">{protest.incident?.description || protest.description}</p>
</Text>
<Card p={4} bg="bg-gray-800/50">
<Text color="text-gray-300" block>{protest.incident?.description || protest.description}</Text>
</Card>
</div>
</Box>
{protest.comment && (
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-400" block mb={2}>
Additional Comment
</label>
<Card className="p-4 bg-gray-800/50">
<p className="text-gray-300">{protest.comment}</p>
</Text>
<Card p={4} bg="bg-gray-800/50">
<Text color="text-gray-300" block>{protest.comment}</Text>
</Card>
</div>
</Box>
)}
{protest.proofVideoUrl && (
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-400" block mb={2}>
Evidence
</label>
<Card className="p-4 bg-gray-800/50">
<a
</Text>
<Card p={4} bg="bg-gray-800/50">
<Box
as="a"
href={protest.proofVideoUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-orange-400 hover:text-orange-300 transition-colors"
display="flex"
alignItems="center"
gap={2}
color="text-orange-400"
hoverTextColor="text-orange-300"
transition
>
<Video className="h-4 w-4" />
<span className="text-sm">View video evidence</span>
</a>
<Icon icon={Video} size={4} />
<Text size="sm">View video evidence</Text>
</Box>
</Card>
</div>
</Box>
)}
</div>
</Stack>
<div className="border-t border-gray-800 pt-6 space-y-4">
<h3 className="text-lg font-semibold text-white">Stewarding Decision</h3>
<Box borderTop borderColor="border-gray-800" pt={6}>
<Stack gap={4}>
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">Stewarding Decision</Heading>
<div className="grid grid-cols-2 gap-3">
<Button
variant={decision === "accept" ? "primary" : "secondary"}
className="flex items-center justify-center gap-2"
onClick={() => setDecision("accept")}
>
<CheckCircle className="h-4 w-4" />
Accept Protest
</Button>
<Button
variant={decision === "reject" ? "primary" : "secondary"}
className="flex items-center justify-center gap-2"
onClick={() => setDecision("reject")}
>
<XCircle className="h-4 w-4" />
Reject Protest
</Button>
</div>
<Box display="grid" gridCols={2} gap={3}>
<Button
variant={decision === "accept" ? "primary" : "secondary"}
fullWidth
onClick={() => setDecision("accept")}
>
<Stack direction="row" align="center" gap={2} center>
<Icon icon={CheckCircle} size={4} />
Accept Protest
</Stack>
</Button>
<Button
variant={decision === "reject" ? "primary" : "secondary"}
fullWidth
onClick={() => setDecision("reject")}
>
<Stack direction="row" align="center" gap={2} center>
<Icon icon={XCircle} size={4} />
Reject Protest
</Stack>
</Button>
</Box>
{decision === "accept" && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
Penalty Type
</label>
{decision === "accept" && (
<Stack gap={4}>
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-400" block mb={2}>
Penalty Type
</Text>
{penaltyTypesLoading ? (
<div className="text-sm text-gray-500">Loading penalty types</div>
) : (
<div className="grid grid-cols-3 gap-2">
{penaltyOptions.map(({ type, name, Icon, colorClass, defaultValue }) => {
const isSelected = penaltyType === type;
return (
<button
key={type}
onClick={() => {
setPenaltyType(type);
setPenaltyValue(defaultValue);
}}
className={`p-3 rounded-lg border transition-all ${
isSelected
? `${colorClass} border-2`
: "border-charcoal-outline hover:border-gray-600 bg-iron-gray/50"
}`}
>
<Icon className={`h-5 w-5 mx-auto mb-1 ${isSelected ? "" : "text-gray-400"}`} />
<p className={`text-xs font-medium ${isSelected ? "" : "text-gray-400"}`}>{name}</p>
</button>
);
})}
</div>
{penaltyTypesLoading ? (
<Text size="sm" color="text-gray-500">Loading penalty types</Text>
) : (
<Box display="grid" gridCols={3} gap={2}>
{penaltyOptions.map(({ type, name, Icon: PenaltyIcon, colorClass, defaultValue }: { type: string; name: string; Icon: LucideIcon; colorClass: string; defaultValue: number }) => {
const isSelected = penaltyType === type;
return (
<Box
key={type}
as="button"
onClick={() => {
setPenaltyType(type);
setPenaltyValue(defaultValue);
}}
p={3}
rounded="lg"
border
borderWidth={isSelected ? "2px" : "1px"}
transition
borderColor={isSelected ? undefined : "border-charcoal-outline"}
bg={isSelected ? undefined : "bg-iron-gray/50"}
hoverBorderColor={!isSelected ? "border-gray-600" : undefined}
// eslint-disable-next-line gridpilot-rules/component-classification
className={isSelected ? colorClass : ""}
>
<Icon icon={PenaltyIcon} size={5} mx="auto" mb={1} color={isSelected ? undefined : "text-gray-400"} />
<Text size="xs" weight="medium" color={isSelected ? undefined : "text-gray-400"} block textAlign="center">{name}</Text>
</Box>
);
})}
</Box>
)}
</Box>
{selectedPenalty?.requiresValue && (
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-400" block mb={2}>
Penalty Value ({selectedPenalty.valueLabel})
</Text>
<Input
type="number"
value={penaltyValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPenaltyValue(Number(e.target.value))}
min={1}
/>
</Box>
)}
</div>
</Stack>
)}
{selectedPenalty?.requiresValue && (
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
Penalty Value ({selectedPenalty.valueLabel})
</label>
<input
type="number"
value={penaltyValue}
onChange={(e) => setPenaltyValue(Number(e.target.value))}
min="1"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-orange-500"
/>
</div>
)}
</div>
)}
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-400" block mb={2}>
Steward Notes *
</Text>
<TextArea
value={stewardNotes}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setStewardNotes(e.target.value)}
placeholder="Explain your decision and reasoning..."
rows={4}
/>
</Box>
</Stack>
</Box>
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
Steward Notes *
</label>
<textarea
value={stewardNotes}
onChange={(e) => setStewardNotes(e.target.value)}
placeholder="Explain your decision and reasoning..."
rows={4}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-orange-500 resize-none"
/>
</div>
</div>
<div className="flex gap-3 pt-4 border-t border-gray-800">
<Box display="flex" gap={3} pt={4} borderTop borderColor="border-gray-800">
<Button
variant="secondary"
className="flex-1"
fullWidth
onClick={onClose}
disabled={submitting}
>
@@ -432,14 +461,14 @@ export function ReviewProtestModal({
</Button>
<Button
variant="primary"
className="flex-1"
fullWidth
onClick={() => setShowConfirmation(true)}
disabled={!decision || !stewardNotes.trim() || submitting}
>
Submit Decision
</Button>
</div>
</div>
</Box>
</Stack>
</Modal>
);
}

View File

@@ -1,40 +0,0 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Surface } from '@/ui/Surface';
export type RulebookSection = 'scoring' | 'conduct' | 'protests' | 'penalties';
interface RulebookTabsProps {
activeSection: RulebookSection;
onSectionChange: (section: RulebookSection) => void;
}
export function RulebookTabs({ activeSection, onSectionChange }: RulebookTabsProps) {
const sections: { id: RulebookSection; label: string }[] = [
{ id: 'scoring', label: 'Scoring' },
{ id: 'conduct', label: 'Conduct' },
{ id: 'protests', label: 'Protests' },
{ id: 'penalties', label: 'Penalties' },
];
return (
<Surface variant="muted" rounded="lg" padding={1} style={{ backgroundColor: '#0f1115', border: '1px solid #262626' }}>
<Box style={{ display: 'flex', gap: '0.25rem' }}>
{sections.map((section) => (
<Button
key={section.id}
variant={activeSection === section.id ? 'secondary' : 'ghost'}
onClick={() => onSectionChange(section.id)}
fullWidth
size="sm"
>
{section.label}
</Button>
))}
</Box>
</Surface>
);
}

View File

@@ -10,7 +10,6 @@ import { Heading } from '@/ui/Heading';
import { Badge } from '@/ui/Badge';
import { Grid } from '@/ui/Grid';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
interface Race {
id: string;
@@ -32,8 +31,8 @@ export function ScheduleRaceCard({ race }: ScheduleRaceCardProps) {
<Card>
<Stack gap={4}>
<Stack direction="row" align="center" gap={3}>
<Box style={{ width: '0.75rem', height: '0.75rem', borderRadius: '9999px', backgroundColor: race.isPast ? '#10b981' : '#3b82f6' }} />
<Heading level={3} style={{ fontSize: '1.125rem' }}>{race.name}</Heading>
<Box w="3" h="3" rounded="full" bg={race.isPast ? 'bg-performance-green' : 'bg-primary-blue'} />
<Heading level={3} fontSize="lg">{race.name}</Heading>
<Badge variant={race.status === 'completed' ? 'success' : 'primary'}>
{race.status === 'completed' ? 'Completed' : 'Scheduled'}
</Badge>

View File

@@ -46,12 +46,14 @@ export function SponsorshipRequestCard({ request }: SponsorshipRequestCardProps)
rounded="lg"
border
padding={4}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{
backgroundColor: `${statusColor}0D`,
borderColor: statusColor
}}
>
<Stack direction="row" align="start" justify="between">
{/* eslint-disable-next-line gridpilot-rules/component-classification */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Stack direction="row" align="center" gap={3} mb={2}>
<Icon icon={statusIcon} size={5} color={statusColor} />

View File

@@ -33,6 +33,7 @@ export function SponsorshipSlotCard({ slot }: SponsorshipSlotCardProps) {
rounded="lg"
border
padding={4}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{
backgroundColor: slot.isAvailable ? 'rgba(16, 185, 129, 0.05)' : 'rgba(38, 38, 38, 0.3)',
borderColor: slot.isAvailable ? '#10b981' : '#262626'
@@ -56,6 +57,7 @@ export function SponsorshipSlotCard({ slot }: SponsorshipSlotCardProps) {
</Stack>
{!slot.isAvailable && slot.sponsoredBy && (
// eslint-disable-next-line gridpilot-rules/component-classification
<Box pt={3} style={{ borderTop: '1px solid #262626' }}>
<Text size="xs" color="text-gray-400" block mb={1}>Sponsored by</Text>
<Text size="sm" weight="medium" color="text-white">{slot.sponsoredBy.name}</Text>

View File

@@ -1,38 +1,54 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import CountryFlag from '@/ui/CountryFlag';
import PlaceholderImage from '@/ui/PlaceholderImage';
import { Link } from '@/ui/Link';
import { Image } from '@/ui/Image';
import { CountryFlag } from '@/ui/CountryFlag';
import { PlaceholderImage } from '@/ui/PlaceholderImage';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
import { Badge } from '@/ui/Badge';
import { Stack } from '@/ui/Stack';
import { routes } from '@/lib/routing/RouteConfig';
import { Icon } from '@/ui/Icon';
import { User, Edit } from 'lucide-react';
// League role display data
const leagueRoleDisplay = {
owner: {
text: 'Owner',
badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30',
bg: 'bg-yellow-500/10',
color: 'text-yellow-500',
borderColor: 'border-yellow-500/30',
},
admin: {
text: 'Admin',
badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
bg: 'bg-purple-500/10',
color: 'text-purple-400',
borderColor: 'border-purple-500/30',
},
steward: {
text: 'Steward',
badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30',
bg: 'bg-blue-500/10',
color: 'text-blue-400',
borderColor: 'border-blue-500/30',
},
member: {
text: 'Member',
badgeClasses: 'bg-primary-blue/10 text-primary-blue border-primary-blue/30',
bg: 'bg-primary-blue/10',
color: 'text-primary-blue',
borderColor: 'border-primary-blue/30',
},
} as const;
// Position background colors
const getPositionBgColor = (position: number): string => {
const getPositionBgColor = (position: number) => {
switch (position) {
case 1: return 'bg-yellow-500/10 border-l-4 border-l-yellow-500';
case 2: return 'bg-gray-300/10 border-l-4 border-l-gray-400';
case 3: return 'bg-amber-600/10 border-l-4 border-l-amber-600';
default: return 'border-l-4 border-l-transparent';
case 1: return { bg: 'bg-yellow-500/10', borderLeft: true, borderColor: 'border-l-yellow-500' };
case 2: return { bg: 'bg-gray-300/10', borderLeft: true, borderColor: 'border-l-gray-400' };
case 3: return { bg: 'bg-amber-600/10', borderLeft: true, borderColor: 'border-l-amber-600' };
default: return { borderLeft: true, borderColor: 'border-l-transparent' };
}
};
@@ -156,151 +172,293 @@ export function StandingsTable({
const hasMembership = !!membership;
return (
<div
<Box
ref={menuRef}
className="absolute right-0 top-full mt-1 z-50 bg-deep-graphite border border-charcoal-outline rounded-lg shadow-xl p-2 min-w-[200px]"
onClick={(e) => e.stopPropagation()}
position="absolute"
right="0"
top="full"
mt={1}
zIndex={50}
bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="lg"
shadow="xl"
p={2}
minWidth="200px"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<div className="text-xs text-gray-400 px-2 py-1 mb-1">
<Text size="xs" color="text-gray-400" px={2} py={1} mb={1} block>
Member Management
</div>
<div className="space-y-1">
</Text>
<Stack gap={1}>
{hasMembership ? (
<>
{/* Role Management for existing members */}
{membership!.role !== 'admin' && membership!.role !== 'owner' && (
<button
onClick={(e) => { e.stopPropagation(); handleRoleChange(driverId, 'admin'); }}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-purple-500/10 rounded flex items-center gap-2 transition-colors"
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleRoleChange(driverId, 'admin'); }}
display="flex"
alignItems="center"
gap={2}
w="full"
textAlign="left"
px={3}
py={2}
rounded="md"
fontSize="14px"
color="text-white"
hoverBg="bg-purple-500/10"
transition
>
<span>🛡</span>
<span>Promote to Admin</span>
</button>
<Text>🛡</Text>
<Text>Promote to Admin</Text>
</Box>
)}
{membership!.role === 'admin' && (
<button
onClick={(e) => { e.stopPropagation(); handleRoleChange(driverId, 'member'); }}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-iron-gray/20 rounded flex items-center gap-2 transition-colors"
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleRoleChange(driverId, 'member'); }}
display="flex"
alignItems="center"
gap={2}
w="full"
textAlign="left"
px={3}
py={2}
rounded="md"
fontSize="14px"
color="text-white"
hoverBg="bg-iron-gray/20"
transition
>
<span></span>
<span>Demote to Member</span>
</button>
<Text></Text>
<Text>Demote to Member</Text>
</Box>
)}
{membership!.role === 'member' && (
<button
onClick={(e) => { e.stopPropagation(); handleRoleChange(driverId, 'steward'); }}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-blue-500/10 rounded flex items-center gap-2 transition-colors"
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleRoleChange(driverId, 'steward'); }}
display="flex"
alignItems="center"
gap={2}
w="full"
textAlign="left"
px={3}
py={2}
rounded="md"
fontSize="14px"
color="text-white"
hoverBg="bg-blue-500/10"
transition
>
<span>🏁</span>
<span>Make Steward</span>
</button>
<Text>🏁</Text>
<Text>Make Steward</Text>
</Box>
)}
{membership!.role === 'steward' && (
<button
onClick={(e) => { e.stopPropagation(); handleRoleChange(driverId, 'member'); }}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-iron-gray/20 rounded flex items-center gap-2 transition-colors"
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleRoleChange(driverId, 'member'); }}
display="flex"
alignItems="center"
gap={2}
w="full"
textAlign="left"
px={3}
py={2}
rounded="md"
fontSize="14px"
color="text-white"
hoverBg="bg-iron-gray/20"
transition
>
<span>🏁</span>
<span>Remove Steward</span>
</button>
<Text>🏁</Text>
<Text>Remove Steward</Text>
</Box>
)}
<div className="border-t border-charcoal-outline my-1"></div>
<button
onClick={(e) => { e.stopPropagation(); handleRemove(driverId); }}
className="w-full text-left px-3 py-2 text-sm text-red-400 hover:bg-red-500/10 rounded flex items-center gap-2 transition-colors"
<Box borderTop borderColor="border-charcoal-outline" my={1} />
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleRemove(driverId); }}
display="flex"
alignItems="center"
gap={2}
w="full"
textAlign="left"
px={3}
py={2}
rounded="md"
fontSize="14px"
color="text-red-400"
hoverBg="bg-red-500/10"
transition
>
<span>🚫</span>
<span>Remove from League</span>
</button>
<Text>🚫</Text>
<Text>Remove from League</Text>
</Box>
</>
) : (
<>
{/* Options for drivers without membership (participating but not formal members) */}
<div className="text-xs text-yellow-400/80 px-2 py-1 mb-1 bg-yellow-500/10 rounded">
Driver not a formal member
</div>
<button
onClick={(e) => { e.stopPropagation(); alert('Add as member - feature coming soon'); }}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-green-500/10 rounded flex items-center gap-2 transition-colors"
<Box bg="bg-yellow-500/10" rounded px={2} py={1} mb={1}>
<Text size="xs" color="text-yellow-400/80">Driver not a formal member</Text>
</Box>
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); alert('Add as member - feature coming soon'); }}
display="flex"
alignItems="center"
gap={2}
w="full"
textAlign="left"
px={3}
py={2}
rounded="md"
fontSize="14px"
color="text-white"
hoverBg="bg-green-500/10"
transition
>
<span></span>
<span>Add as Member</span>
</button>
<button
onClick={(e) => { e.stopPropagation(); handleRemove(driverId); }}
className="w-full text-left px-3 py-2 text-sm text-red-400 hover:bg-red-500/10 rounded flex items-center gap-2 transition-colors"
<Text></Text>
<Text>Add as Member</Text>
</Box>
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleRemove(driverId); }}
display="flex"
alignItems="center"
gap={2}
w="full"
textAlign="left"
px={3}
py={2}
rounded="md"
fontSize="14px"
color="text-red-400"
hoverBg="bg-red-500/10"
transition
>
<span>🚫</span>
<span>Remove from Standings</span>
</button>
<Text>🚫</Text>
<Text>Remove from Standings</Text>
</Box>
</>
)}
</div>
</div>
</Stack>
</Box>
);
};
const PointsActionMenu = () => {
return (
<div
<Box
ref={menuRef}
className="absolute right-0 top-full mt-1 z-50 bg-deep-graphite border border-charcoal-outline rounded-lg shadow-xl p-2 min-w-[180px]"
onClick={(e) => e.stopPropagation()}
position="absolute"
right="0"
top="full"
mt={1}
zIndex={50}
bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="lg"
shadow="xl"
p={2}
minWidth="180px"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<div className="text-xs text-gray-400 px-2 py-1 mb-1">
<Text size="xs" color="text-gray-400" px={2} py={1} mb={1} block>
Score Actions
</div>
<div className="space-y-1">
<button
onClick={(e) => { e.stopPropagation(); alert('View detailed stats - feature coming soon'); }}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-iron-gray/20 rounded flex items-center gap-2 transition-colors"
</Text>
<Stack gap={1}>
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); alert('View detailed stats - feature coming soon'); }}
display="flex"
alignItems="center"
gap={2}
w="full"
textAlign="left"
px={3}
py={2}
rounded="md"
color="text-white"
hoverBg="bg-iron-gray/20"
transition
fontSize="14px"
>
<span>📊</span>
<span>View Details</span>
</button>
<button
onClick={(e) => { e.stopPropagation(); alert('Manual adjustment - feature coming soon'); }}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-iron-gray/20 rounded flex items-center gap-2 transition-colors"
<Text>📊</Text>
<Text>View Details</Text>
</Box>
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); alert('Manual adjustment - feature coming soon'); }}
display="flex"
alignItems="center"
gap={2}
w="full"
textAlign="left"
px={3}
py={2}
rounded="md"
color="text-white"
hoverBg="bg-iron-gray/20"
transition
fontSize="14px"
>
<span></span>
<span>Adjust Points</span>
</button>
<button
onClick={(e) => { e.stopPropagation(); alert('Race history - feature coming soon'); }}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-iron-gray/20 rounded flex items-center gap-2 transition-colors"
<Text></Text>
<Text>Adjust Points</Text>
</Box>
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); alert('Race history - feature coming soon'); }}
display="flex"
alignItems="center"
gap={2}
w="full"
textAlign="left"
px={3}
py={2}
rounded="md"
color="text-white"
hoverBg="bg-iron-gray/20"
transition
fontSize="14px"
>
<span>📝</span>
<span>Race History</span>
</button>
</div>
</div>
<Text>📝</Text>
<Text>Race History</Text>
</Box>
</Stack>
</Box>
);
};
if (standings.length === 0) {
return (
<div className="text-center py-8 text-gray-400">
No standings available
</div>
<Box textAlign="center" py={8}>
<Text color="text-gray-400">No standings available</Text>
</Box>
);
}
return (
<div className="overflow-x-auto overflow-y-visible">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-center py-3 px-3 font-semibold text-gray-400 w-14">Pos</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Driver</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Team</th>
<th className="text-right py-3 px-4 font-semibold text-gray-400">Points</th>
<th className="text-center py-3 px-4 font-semibold text-gray-400">Races</th>
<th className="text-right py-3 px-4 font-semibold text-gray-400">Avg Finish</th>
<th className="text-right py-3 px-4 font-semibold text-gray-400">Penalty</th>
<th className="text-right py-3 px-4 font-semibold text-gray-400">Bonus</th>
</tr>
</thead>
<tbody>
<Box overflow="auto">
<Table>
<TableHead>
<TableRow>
<TableHeader textAlign="center" w="14">Pos</TableHeader>
<TableHeader>Driver</TableHeader>
<TableHeader>Team</TableHeader>
<TableHeader textAlign="right">Points</TableHeader>
<TableHeader textAlign="center">Races</TableHeader>
<TableHeader textAlign="right">Avg Finish</TableHeader>
<TableHeader textAlign="right">Penalty</TableHeader>
<TableHeader textAlign="right">Bonus</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{standings.map((row) => {
const driver = getDriver(row.driverId);
const membership = getMembership(row.driverId);
@@ -311,11 +469,16 @@ export function StandingsTable({
const isPointsMenuOpen = activeMenu?.driverId === row.driverId && activeMenu?.type === 'points';
const isMe = isCurrentUser(row.driverId);
const posConfig = getPositionBgColor(row.position);
return (
<tr
<TableRow
key={row.driverId}
className={`border-b border-charcoal-outline/50 transition-all duration-200 ${getPositionBgColor(row.position)} ${isRowHovered ? 'bg-iron-gray/10' : ''} ${isMe ? 'ring-2 ring-primary-blue/50 ring-inset bg-primary-blue/5' : ''}`}
bg={isMe ? 'bg-primary-blue/5' : posConfig.bg}
borderLeft={posConfig.borderLeft}
borderColor={posConfig.borderColor}
hoverBg="bg-iron-gray/10"
ring={isMe ? 'ring-2 ring-primary-blue/50 ring-inset' : ''}
onMouseEnter={() => setHoveredRow(row.driverId)}
onMouseLeave={() => {
setHoveredRow(null);
@@ -325,150 +488,198 @@ export function StandingsTable({
}}
>
{/* Position */}
<td className="py-3 px-3 text-center">
<div className={`inline-flex items-center justify-center w-8 h-8 rounded-full font-bold ${
row.position === 1 ? 'bg-yellow-500 text-black' :
row.position === 2 ? 'bg-gray-400 text-black' :
row.position === 3 ? 'bg-amber-600 text-white' :
'bg-charcoal-outline text-white'
}`}>
<TableCell textAlign="center" w="14">
<Box
display="inline-flex"
alignItems="center"
justifyContent="center"
w="8"
h="8"
rounded="full"
weight="bold"
bg={
row.position === 1 ? 'bg-yellow-500' :
row.position === 2 ? 'bg-gray-400' :
row.position === 3 ? 'bg-amber-600' :
'bg-charcoal-outline'
}
color={
row.position === 1 ? 'text-black' :
row.position === 2 ? 'text-black' :
'text-white'
}
>
{row.position}
</div>
</td>
</Box>
</TableCell>
{/* Driver with Rating and Nationality */}
<td className="py-3 px-4 relative">
<div className="flex items-center gap-3">
{/* Avatar */}
<div className="relative">
<div className="w-10 h-10 rounded-full bg-primary-blue/20 overflow-hidden flex items-center justify-center shrink-0">
{driver && (
driver.avatarUrl ? (
<Image
src={driver.avatarUrl}
alt={driver.name}
width={40}
height={40}
className="w-full h-full object-cover"
/>
) : (
<PlaceholderImage size={40} />
)
)}
</div>
{/* Nationality flag */}
{driver && driver.country && (
<div className="absolute -bottom-1 -right-1">
<CountryFlag countryCode={driver.country} size="sm" />
</div>
)}
</div>
{/* Name and Rating */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Link
href={`/drivers/${row.driverId}`}
className="font-medium text-white truncate hover:text-primary-blue transition-colors"
>
{driver?.name || 'Unknown Driver'}
</Link>
{isMe && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-primary-blue/20 text-primary-blue border border-primary-blue/30">
You
</span>
{/* Driver with Rating and Nationality */}
<TableCell position="relative">
<Box display="flex" alignItems="center" gap={3}>
{/* Avatar */}
<Box position="relative">
<Box
w="10"
h="10"
rounded="full"
bg="bg-primary-blue/20"
overflow="hidden"
display="flex"
alignItems="center"
justifyContent="center"
flexShrink={0}
>
{driver && (
driver.avatarUrl ? (
<Image
src={driver.avatarUrl}
alt={driver.name}
width={40}
height={40}
fullWidth
fullHeight
objectFit="cover"
/>
) : (
<PlaceholderImage size={40} />
)
)}
{roleDisplay && roleDisplay.text !== 'Member' && (
<span className={`px-2 py-0.5 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
{roleDisplay.text}
</span>
)}
</div>
<div className="text-xs flex items-center gap-1">
{/* Rating intentionally omitted until API provides driver stats */}
</div>
</div>
{/* Hover Actions for Member Management */}
{isAdmin && canModify && (
<div className="flex items-center gap-1" style={{ opacity: isRowHovered || isMemberMenuOpen ? 1 : 0, transition: 'opacity 0.2s', visibility: isRowHovered || isMemberMenuOpen ? 'visible' : 'hidden' }}>
<button
onClick={(e) => { e.stopPropagation(); setActiveMenu(isMemberMenuOpen ? null : { driverId: row.driverId, type: 'member' }); }}
className={`p-1.5 rounded transition-colors ${isMemberMenuOpen ? 'bg-primary-blue/20 text-primary-blue' : 'text-gray-400 hover:text-white hover:bg-iron-gray/30'}`}
title="Manage member"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</button>
</div>
</Box>
{/* Nationality flag */}
{driver && driver.country && (
<Box position="absolute" bottom="-1" right="-1">
<CountryFlag countryCode={driver.country} size="sm" />
</Box>
)}
</div>
{isMemberMenuOpen && <MemberActionMenu driverId={row.driverId} />}
</td>
</Box>
{/* Name and Rating */}
<Box flexGrow={1} minWidth="0">
<Box display="flex" alignItems="center" gap={2}>
<Link
href={routes.driver.detail(row.driverId)}
weight="medium"
color="text-white"
truncate
hoverTextColor="text-primary-blue"
transition
>
{driver?.name || 'Unknown Driver'}
</Link>
{isMe && (
<Badge variant="primary">You</Badge>
)}
{roleDisplay && roleDisplay.text !== 'Member' && (
<Box
as="span"
px={2}
py={0.5}
fontSize="12px"
weight="medium"
rounded="md"
border
bg={roleDisplay.bg}
color={roleDisplay.color}
borderColor={roleDisplay.borderColor}
>
{roleDisplay.text}
</Box>
)}
</Box>
</Box>
{/* Team */}
<td className="py-3 px-4">
<span className="text-gray-300">{row.teamName ?? '—'}</span>
</td>
{/* Hover Actions for Member Management */}
{isAdmin && canModify && (
<Box
display="flex"
alignItems="center"
gap={1}
opacity={isRowHovered || isMemberMenuOpen ? 1 : 0}
transition
visibility={isRowHovered || isMemberMenuOpen ? 'visible' : 'hidden'}
>
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); setActiveMenu(isMemberMenuOpen ? null : { driverId: row.driverId, type: 'member' }); }}
p={1.5}
rounded="md"
bg={isMemberMenuOpen ? 'bg-primary-blue/20' : ''}
color={isMemberMenuOpen ? 'text-primary-blue' : 'text-gray-400'}
hoverColor={!isMemberMenuOpen ? 'text-white' : ''}
hoverBg={!isMemberMenuOpen ? 'bg-iron-gray/30' : ''}
transition
title="Manage member"
>
<Icon icon={User} size={4} />
</Box>
</Box>
)}
</Box>
{isMemberMenuOpen && <MemberActionMenu driverId={row.driverId} />}
</TableCell>
{/* Total Points with Hover Action */}
<td className="py-3 px-4 text-right relative">
<div className="flex items-center justify-end gap-2">
<span className="text-white font-bold text-lg">{row.totalPoints}</span>
{isAdmin && canModify && (
<button
onClick={(e) => { e.stopPropagation(); setActiveMenu(isPointsMenuOpen ? null : { driverId: row.driverId, type: 'points' }); }}
className={`p-1 rounded transition-colors ${isPointsMenuOpen ? 'bg-primary-blue/20 text-primary-blue' : 'text-gray-400 hover:text-white hover:bg-iron-gray/30'}`}
style={{ opacity: isRowHovered || isPointsMenuOpen ? 1 : 0, transition: 'opacity 0.2s', visibility: isRowHovered || isPointsMenuOpen ? 'visible' : 'hidden' }}
{/* Team */}
<TableCell>
<Text color="text-gray-300">{row.teamName ?? '—'}</Text>
</TableCell>
{/* Total Points with Hover Action */}
<TableCell textAlign="right" position="relative">
<Box display="flex" alignItems="center" justifyContent="end" gap={2}>
<Text color="text-white" weight="bold" size="lg">{row.totalPoints}</Text>
{isAdmin && canModify && (
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); setActiveMenu(isPointsMenuOpen ? null : { driverId: row.driverId, type: 'points' }); }}
p={1}
rounded="md"
bg={isPointsMenuOpen ? 'bg-primary-blue/20' : ''}
color={isPointsMenuOpen ? 'text-primary-blue' : 'text-gray-400'}
hoverColor={!isPointsMenuOpen ? 'text-white' : ''}
hoverBg={!isPointsMenuOpen ? 'bg-iron-gray/30' : ''}
transition
opacity={isRowHovered || isPointsMenuOpen ? 1 : 0}
visibility={isRowHovered || isPointsMenuOpen ? 'visible' : 'hidden'}
title="Score actions"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
)}
</div>
{isPointsMenuOpen && <PointsActionMenu />}
</td>
<Icon icon={Edit} size={3} />
</Box>
)}
</Box>
{isPointsMenuOpen && <PointsActionMenu />}
</TableCell>
{/* Races (Finished/Started) */}
<td className="py-3 px-4 text-center">
<span className="text-white">{row.racesFinished}</span>
<span className="text-gray-500">/{row.racesStarted}</span>
</td>
{/* Races (Finished/Started) */}
<TableCell textAlign="center">
<Text color="text-white">{row.racesFinished}</Text>
<Text color="text-gray-500">/{row.racesStarted}</Text>
</TableCell>
{/* Avg Finish */}
<td className="py-3 px-4 text-right">
<span className="text-gray-300">
{row.avgFinish !== null ? row.avgFinish.toFixed(1) : '—'}
</span>
</td>
{/* Avg Finish */}
<TableCell textAlign="right">
<Text color="text-gray-300">
{row.avgFinish !== null ? row.avgFinish.toFixed(1) : '—'}
</Text>
</TableCell>
{/* Penalty */}
<td className="py-3 px-4 text-right">
<span className={row.penaltyPoints > 0 ? 'text-red-400 font-medium' : 'text-gray-500'}>
{row.penaltyPoints > 0 ? `-${row.penaltyPoints}` : '—'}
</span>
</td>
{/* Penalty */}
<TableCell textAlign="right">
<Text color={row.penaltyPoints > 0 ? 'text-red-400' : 'text-gray-500'} weight={row.penaltyPoints > 0 ? 'medium' : 'normal'}>
{row.penaltyPoints > 0 ? `-${row.penaltyPoints}` : '—'}
</Text>
</TableCell>
{/* Bonus */}
<td className="py-3 px-4 text-right">
<span className={row.bonusPoints !== 0 ? 'text-green-400 font-medium' : 'text-gray-500'}>
{row.bonusPoints !== 0 ? `+${row.bonusPoints}` : '—'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
<style jsx>{`
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
`}</style>
</div>
{/* Bonus */}
<TableCell textAlign="right">
<Text color={row.bonusPoints !== 0 ? 'text-green-400' : 'text-gray-500'} weight={row.bonusPoints !== 0 ? 'medium' : 'normal'}>
{row.bonusPoints !== 0 ? `+${row.bonusPoints}` : '—'}
</Text>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Box>
);
}

View File

@@ -1,5 +1,7 @@
import React from 'react';
import { CheckCircle, Clock, Gavel } from 'lucide-react';
import { Box } from '@/ui/Box';
import { StatBox } from '@/ui/StatBox';
interface StewardingStatsProps {
totalPending: number;
@@ -7,30 +9,27 @@ interface StewardingStatsProps {
totalPenalties: number;
}
export default function StewardingStats({ totalPending, totalResolved, totalPenalties }: StewardingStatsProps) {
export function StewardingStats({ totalPending, totalResolved, totalPenalties }: StewardingStatsProps) {
return (
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-warning-amber mb-1">
<Clock className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Pending Review</span>
</div>
<div className="text-2xl font-bold text-white">{totalPending}</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-performance-green mb-1">
<CheckCircle className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Resolved</span>
</div>
<div className="text-2xl font-bold text-white">{totalResolved}</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-red-400 mb-1">
<Gavel className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Penalties</span>
</div>
<div className="text-2xl font-bold text-white">{totalPenalties}</div>
</div>
</div>
<Box display="grid" responsiveGridCols={{ base: 1, sm: 3 }} gap={4} mb={6}>
<StatBox
icon={Clock}
label="Pending Review"
value={totalPending}
color="var(--warning-amber)"
/>
<StatBox
icon={CheckCircle}
label="Resolved"
value={totalResolved}
color="var(--performance-green)"
/>
<StatBox
icon={Gavel}
label="Penalties"
value={totalPenalties}
color="var(--racing-red)"
/>
</Box>
);
}
}

View File

@@ -41,33 +41,34 @@ export function TransactionRow({ transaction }: TransactionRowProps) {
rounded="lg"
border
padding={4}
style={{ backgroundColor: 'rgba(38, 38, 38, 0.3)', borderColor: '#262626' }}
bg="bg-iron-gray/30"
borderColor="border-charcoal-outline"
>
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={3}>
<Box style={{ flexShrink: 0 }}>
<Box flexShrink={0}>
<Icon icon={getTransactionIcon(transaction.type)} size={4} color={transaction.typeColor} />
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Box minWidth="0" flexGrow={1}>
<Text size="sm" weight="medium" color="text-white" block truncate>
{transaction.description}
</Text>
<Stack direction="row" align="center" gap={2} mt={1}>
<Text size="xs" color="text-gray-500">{transaction.formattedDate}</Text>
<Text size="xs" color="text-gray-500"></Text>
<Text size="xs" style={{ color: transaction.typeColor, textTransform: 'capitalize' }}>
<Text size="xs" color={transaction.typeColor} transform="capitalize">
{transaction.type}
</Text>
<Text size="xs" color="text-gray-500"></Text>
<Text size="xs" style={{ color: transaction.statusColor, textTransform: 'capitalize' }}>
<Text size="xs" color={transaction.statusColor} transform="capitalize">
{transaction.status}
</Text>
</Stack>
</Box>
</Stack>
<Box style={{ textAlign: 'right' }}>
<Text size="lg" weight="semibold" style={{ color: transaction.amountColor }}>
<Box textAlign="right">
<Text size="lg" weight="semibold" color={transaction.amountColor}>
{transaction.formattedAmount}
</Text>
</Box>