website refactor

This commit is contained in:
2026-01-16 01:00:03 +01:00
parent ce7be39155
commit a98e3e3166
286 changed files with 5522 additions and 5261 deletions

View File

@@ -1,37 +1,42 @@
'use client';
import { useState, useRef, useCallback } from 'react';
import {
Trophy,
Users,
Globe,
Award,
Search,
Plus,
ChevronLeft,
ChevronRight,
Sparkles,
Flag,
Filter,
Flame,
Clock,
Target,
Timer,
} from 'lucide-react';
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Input } from '@/ui/Input';
import { Heading } from '@/ui/Heading';
import { Container } from '@/ui/Container';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { PageHero } from '@/ui/PageHero';
import { routes } from '@/lib/routing/RouteConfig';
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Container } from '@/ui/Container';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { Heading } from '@/ui/Heading';
import { Icon as UIIcon } from '@/ui/Icon';
import { Input } from '@/ui/Input';
import { Link as UILink } from '@/ui/Link';
import { PageHero } from '@/ui/PageHero';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import {
Award,
ChevronLeft,
ChevronRight,
Clock,
Filter,
Flag,
Flame,
Globe,
Plus,
Search,
Sparkles,
Target,
Timer,
Trophy,
Users,
type LucideIcon,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useCallback, useRef, useState } from 'react';
// ============================================================================
// TYPES
@@ -55,7 +60,7 @@ type CategoryId =
interface Category {
id: CategoryId;
label: string;
icon: React.ElementType;
icon: LucideIcon;
description: string;
filter: (league: LeaguesViewData['leagues'][number]) => boolean;
color?: string;
@@ -63,7 +68,7 @@ interface Category {
interface LeagueSliderProps {
title: string;
icon: React.ElementType;
icon: LucideIcon;
description: string;
leagues: LeaguesViewData['leagues'];
autoScroll?: boolean;
@@ -306,74 +311,71 @@ function LeagueSlider({
if (leagues.length === 0) return null;
return (
<div className="mb-10">
<Box mb={10}>
{/* Section header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-xl bg-iron-gray border border-charcoal-outline`}>
<Icon className={`w-5 h-5 ${iconColor}`} />
</div>
<div>
<h2 className="text-lg font-semibold text-white">{title}</h2>
<p className="text-xs text-gray-500">{description}</p>
</div>
<span className="ml-2 px-2 py-0.5 rounded-full text-xs bg-charcoal-outline/50 text-gray-400">
<Box display="flex" alignItems="center" justifyContent="between" mb={4}>
<Stack direction="row" align="center" gap={3}>
<Box display="flex" h={10} w={10} alignItems="center" justifyContent="center" rounded="xl" bg="bg-iron-gray" border borderColor="border-charcoal-outline">
<UIIcon icon={Icon} size={5} color={iconColor} />
</Box>
<Box>
<Heading level={2}>{title}</Heading>
<Text size="xs" color="text-gray-500">{description}</Text>
</Box>
<Box as="span" ml={2} px={2} py={0.5} rounded="full" fontSize="0.75rem" bg="bg-charcoal-outline/50" color="text-gray-400">
{leagues.length}
</span>
</div>
</Box>
</Stack>
{/* Navigation arrows */}
<div className="flex items-center gap-2">
<button
<Stack direction="row" align="center" gap={2}>
<Button
type="button"
variant="secondary"
onClick={() => scroll('left')}
disabled={!canScrollLeft}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-all ${
canScrollLeft
? 'bg-iron-gray border border-charcoal-outline text-white hover:border-primary-blue hover:text-primary-blue'
: 'bg-iron-gray/30 border border-charcoal-outline/30 text-gray-600 cursor-not-allowed'
}`}
size="sm"
w="2rem"
h="2rem"
p={0}
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
<UIIcon icon={ChevronLeft} size={4} />
</Button>
<Button
type="button"
variant="secondary"
onClick={() => scroll('right')}
disabled={!canScrollRight}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-all ${
canScrollRight
? 'bg-iron-gray border border-charcoal-outline text-white hover:border-primary-blue hover:text-primary-blue'
: 'bg-iron-gray/30 border border-charcoal-outline/30 text-gray-600 cursor-not-allowed'
}`}
size="sm"
w="2rem"
h="2rem"
p={0}
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
<UIIcon icon={ChevronRight} size={4} />
</Button>
</Stack>
</Box>
{/* Scrollable container with fade edges */}
<div className="relative">
<Box position="relative">
{/* Left fade gradient */}
<div className="absolute left-0 top-0 bottom-4 w-12 bg-gradient-to-r from-deep-graphite to-transparent z-10 pointer-events-none" />
<Box position="absolute" top={0} bottom={4} left={0} w="3rem" bg="bg-gradient-to-r from-deep-graphite to-transparent" zIndex={10} pointerEvents="none" />
{/* Right fade gradient */}
<div className="absolute right-0 top-0 bottom-4 w-12 bg-gradient-to-l from-deep-graphite to-transparent z-10 pointer-events-none" />
<Box position="absolute" top={0} bottom={4} right={0} w="3rem" bg="bg-gradient-to-l from-deep-graphite to-transparent" zIndex={10} pointerEvents="none" />
<div
<Box
ref={scrollRef}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
className="flex gap-4 overflow-x-auto pb-4 px-4"
style={{
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
display="flex"
gap={4}
overflow="auto"
pb={4}
px={4}
hideScrollbar
>
<style jsx>{`
div::-webkit-scrollbar {
display: none;
}
`}</style>
{leagues.map((league) => {
// TODO wtf we have builders for this
// Convert ViewData to ViewModel for LeagueCard
const viewModel: LeagueSummaryViewModel = {
id: league.id,
@@ -396,16 +398,16 @@ function LeagueSlider({
};
return (
<div key={league.id} className="flex-shrink-0 w-[320px] h-full">
<a href={`/leagues/${league.id}`} className="block h-full">
<Box key={league.id} flexShrink={0} w="320px" h="full">
<UILink href={routes.league.detail(league.id)} block h="full">
<LeagueCard league={viewModel} />
</a>
</div>
</UILink>
</Box>
);
})}
</div>
</div>
</div>
</Box>
</Box>
</Box>
);
}
@@ -413,9 +415,10 @@ function LeagueSlider({
// MAIN TEMPLATE COMPONENT
// ============================================================================
export function LeaguesClient({
export function LeaguesPageClient({
viewData,
}: LeaguesTemplateProps) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
const [activeCategory, setActiveCategory] = useState<CategoryId>('all');
const [showFilters, setShowFilters] = useState(false);
@@ -480,7 +483,7 @@ export function LeaguesClient({
actions={[
{
label: 'Create League',
onClick: () => { window.location.href = '/leagues/create'; },
onClick: () => { router.push(routes.league.create); },
icon: Plus,
description: 'Set up your own racing series'
}
@@ -491,60 +494,57 @@ export function LeaguesClient({
<Box mb={6}>
<Stack direction="row" gap={4} wrap>
{/* Search */}
<Box display="flex" position="relative" style={{ flex: 1 }}>
<Box position="absolute" style={{ left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Search className="w-5 h-5 text-gray-500" />
</Box>
<Box display="flex" position="relative" flexGrow={1}>
<Input
type="text"
placeholder="Search leagues by name, description, or game..."
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
className="pl-11"
icon={<UIIcon icon={Search} size={5} color="text-gray-500" />}
/>
</Box>
{/* Filter toggle (mobile) */}
<Box display="none" className="lg:hidden">
<Box display={{ base: 'block', lg: 'none' }}>
<Button
type="button"
variant="secondary"
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2"
>
<Filter className="w-4 h-4" />
Filters
<Stack direction="row" align="center" gap={2}>
<UIIcon icon={Filter} size={4} />
<Text>Filters</Text>
</Stack>
</Button>
</Box>
</Stack>
{/* Category Tabs */}
<Box mt={4} display={showFilters ? 'block' : 'none'} className="lg:block">
<Box mt={4} display={showFilters ? 'block' : { base: 'none', lg: 'block' }}>
<Stack direction="row" gap={2} wrap>
{CATEGORIES.map((category) => {
const Icon = category.icon;
const count = leaguesByCategory[category.id].length;
const isActive = activeCategory === category.id;
return (
<button
<Button
key={category.id}
type="button"
variant={isActive ? 'primary' : 'secondary'}
onClick={() => setActiveCategory(category.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all duration-200 ${
isActive
? 'bg-primary-blue text-white shadow-[0_0_15px_rgba(25,140,255,0.3)]'
: 'bg-iron-gray/60 text-gray-400 border border-charcoal-outline hover:border-gray-500 hover:text-white'
}`}
size="sm"
rounded="full"
>
<Icon className={`w-3.5 h-3.5 ${!isActive && category.color ? category.color : ''}`} />
<span>{category.label}</span>
{count > 0 && (
<span className={`px-1.5 py-0.5 rounded-full text-[10px] ${isActive ? 'bg-white/20' : 'bg-charcoal-outline/50'}`}>
{count}
</span>
)}
</button>
<Stack direction="row" align="center" gap={1.5}>
<UIIcon icon={category.icon} size={3.5} color={!isActive && category.color ? category.color : undefined} />
<Text size="xs" weight="medium">{category.label}</Text>
{count > 0 && (
<Box as="span" px={1.5} py={0.5} rounded="full" fontSize="10px" bg={isActive ? 'bg-white/20' : 'bg-charcoal-outline/50'}>
{count}
</Box>
)}
</Stack>
</Button>
);
})}
</Stack>
@@ -554,24 +554,29 @@ export function LeaguesClient({
{/* Content */}
{viewData.leagues.length === 0 ? (
/* Empty State */
<Card className="text-center py-16">
<Box maxWidth="28rem" mx="auto">
<Box display="flex" center mb={6} rounded="2xl" p={4} className="bg-primary-blue/10 border border-primary-blue/20 mx-auto w-16 h-16">
<Trophy className="w-8 h-8 text-primary-blue" />
<Card>
<Box py={16} textAlign="center">
<Box maxWidth="28rem" mx="auto">
<Box display="flex" center mb={6} rounded="2xl" p={4} bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" mx="auto" w="4rem" h="4rem">
<UIIcon icon={Trophy} size={8} color="text-primary-blue" />
</Box>
<Heading level={2}>
No leagues yet
</Heading>
<Box mt={3} mb={8}>
<Text color="text-gray-400">
Be the first to create a racing series. Start your own league and invite drivers to compete for glory.
</Text>
</Box>
<Button
onClick={() => { router.push(routes.league.create); }}
>
<Stack direction="row" align="center" gap={2}>
<UIIcon icon={Sparkles} size={4} />
<Text>Create Your First League</Text>
</Stack>
</Button>
</Box>
<Heading level={2} className="text-2xl mb-3">
No leagues yet
</Heading>
<Text color="text-gray-400" mb={8} block>
Be the first to create a racing series. Start your own league and invite drivers to compete for glory.
</Text>
<Button
onClick={() => { window.location.href = '/leagues/create'; }}
className="inline-flex items-center gap-2 px-6 py-3 bg-primary-blue text-white rounded-lg hover:bg-blue-600 transition-colors"
>
<Sparkles className="w-4 h-4" />
Create Your First League
</Button>
</Box>
</Card>
) : activeCategory === 'all' && !searchQuery ? (
@@ -602,15 +607,15 @@ export function LeaguesClient({
<Box>
{categoryFilteredLeagues.length > 0 ? (
<>
<Box display="flex" align="center" justify="between" mb={6}>
<Box display="flex" alignItems="center" justifyContent="between" mb={6}>
<Text size="sm" color="text-gray-400">
Showing <Text color="text-white" weight="medium">{categoryFilteredLeagues.length}</Text>{' '}
{categoryFilteredLeagues.length === 1 ? 'league' : 'leagues'}
{searchQuery && (
<span>
<Box as="span">
{' '}
for "<Text color="text-primary-blue">{searchQuery}</Text>"
</span>
for &quot;<Text color="text-primary-blue">{searchQuery}</Text>&quot;
</Box>
)}
</Text>
</Box>
@@ -639,31 +644,33 @@ export function LeaguesClient({
return (
<GridItem key={league.id}>
<a href={`/leagues/${league.id}`} className="block h-full">
<UILink href={routes.league.detail(league.id)} block h="full">
<LeagueCard league={viewModel} />
</a>
</UILink>
</GridItem>
);
})}
</Grid>
</>
) : (
<Card className="text-center py-12">
<Stack align="center" gap={4}>
<Search className="w-10 h-10 text-gray-600" />
<Text color="text-gray-400">
No leagues found{searchQuery ? ` matching "${searchQuery}"` : ' in this category'}
</Text>
<Button
variant="secondary"
onClick={() => {
setSearchQuery('');
setActiveCategory('all');
}}
>
Clear filters
</Button>
</Stack>
<Card>
<Box py={12} textAlign="center">
<Stack align="center" gap={4}>
<UIIcon icon={Search} size={10} color="text-gray-600" />
<Text color="text-gray-400">
No leagues found{searchQuery ? ` matching "${searchQuery}"` : ' in this category'}
</Text>
<Button
variant="secondary"
onClick={() => {
setSearchQuery('');
setActiveCategory('all');
}}
>
Clear filters
</Button>
</Stack>
</Box>
</Card>
)}
</Box>

View File

@@ -1,5 +1,5 @@
import { notFound } from 'next/navigation';
import { LeagueDetailPageQuery } from '@/lib/page-queries/page-queries/LeagueDetailPageQuery';
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
import { Text } from '@/ui/Text';

View File

@@ -1,6 +1,6 @@
import { notFound } from 'next/navigation';
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
import { LeagueDetailPageQuery } from '@/lib/page-queries/page-queries/LeagueDetailPageQuery';
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder';
import { ErrorBanner } from '@/ui/ErrorBanner';
@@ -22,8 +22,6 @@ export default async function Page({ params }: Props) {
case 'redirect':
// In a real app, this would redirect to login
notFound();
case 'LEAGUE_FETCH_FAILED':
case 'UNKNOWN_ERROR':
default:
// Return error state
return (
@@ -49,5 +47,9 @@ export default async function Page({ params }: Props) {
sponsors: [],
});
return <LeagueDetailTemplate viewData={viewData} tabs={[]} children={null} />;
}
return (
<LeagueDetailTemplate viewData={viewData} tabs={[]}>
{null}
</LeagueDetailTemplate>
);
}

View File

@@ -10,8 +10,11 @@ import {
useRejectJoinRequest,
useUpdateMemberRole,
useRemoveMember,
} from "@/lib/hooks/league/useLeagueRosterAdmin";
} from "@/hooks/league/useLeagueRosterAdmin";
import { RosterAdminTemplate } from '@/templates/RosterAdminTemplate';
import type { JoinRequestData, RosterMemberData } from '@/lib/view-data/LeagueRosterAdminViewData';
import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO';
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member'];
@@ -71,10 +74,25 @@ export function RosterAdminPage() {
await removeMemberMutation.mutateAsync({ leagueId, driverId });
};
const viewData = useMemo(() => ({
leagueId,
joinRequests: joinRequests.map((req: LeagueRosterJoinRequestDTO): JoinRequestData => ({
id: req.id,
driver: req.driver as { id: string; name: string },
requestedAt: req.requestedAt,
message: req.message || undefined,
})),
members: members.map((m: LeagueRosterMemberDTO): RosterMemberData => ({
driverId: m.driverId,
driver: m.driver as { id: string; name: string },
role: m.role,
joinedAt: m.joinedAt,
})),
}), [leagueId, joinRequests, members]);
return (
<RosterAdminTemplate
joinRequests={joinRequests}
members={members}
viewData={viewData}
loading={loading}
pendingCountLabel={pendingCountLabel}
onApprove={handleApprove}
@@ -84,4 +102,4 @@ export function RosterAdminPage() {
roleOptions={ROLE_OPTIONS}
/>
);
}
}

View File

@@ -1,4 +1,4 @@
import { LeagueRulebookPageQuery } from '@/lib/page-queries/page-queries/LeagueRulebookPageQuery';
import { LeagueRulebookPageQuery } from '@/lib/page-queries/LeagueRulebookPageQuery';
import { RulebookTemplate } from '@/templates/RulebookTemplate';
import { notFound } from 'next/navigation';
@@ -17,18 +17,21 @@ export default async function Page({ params }: Props) {
if (result.isErr()) {
const error = result.getError();
if (error.type === 'notFound') {
if (error === 'notFound') {
notFound();
}
// For serverError, show the template with empty data
return <RulebookTemplate viewData={{
leagueId,
scoringConfig: {
gameName: 'Unknown',
scoringPresetName: 'Unknown',
championships: [],
dropPolicySummary: 'Unknown',
},
gameName: 'Unknown',
scoringPresetName: 'Unknown',
championshipsCount: 0,
sessionTypes: 'None',
dropPolicySummary: 'Unknown',
hasActiveDropPolicy: false,
positionPoints: [],
bonusPoints: [],
hasBonusPoints: false,
}} />;
}

View File

@@ -0,0 +1,224 @@
'use client';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { LeagueAdminScheduleTemplate } from '@/templates/LeagueAdminScheduleTemplate';
import {
useLeagueAdminStatus,
useLeagueSeasons,
useLeagueAdminSchedule
} from "@/hooks/league/useLeagueScheduleAdminPageData";
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
export function LeagueAdminSchedulePageClient() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId() || '';
const queryClient = useQueryClient();
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
// Form state
const [seasonId, setSeasonId] = useState<string>('');
const [track, setTrack] = useState('');
const [car, setCar] = useState('');
const [scheduledAtIso, setScheduledAtIso] = useState('');
const [editingRaceId, setEditingRaceId] = useState<string | null>(null);
// Check admin status using domain hook
const { data: isAdmin, isLoading: isAdminLoading } = useLeagueAdminStatus(leagueId, currentDriverId);
// Load seasons using domain hook
const { data: seasonsData, isLoading: seasonsLoading } = useLeagueSeasons(leagueId, !!isAdmin);
// Auto-select season
const selectedSeasonId = seasonId || (seasonsData && seasonsData.length > 0
? (seasonsData.find((s) => s.status === 'active') ?? seasonsData[0])?.seasonId
: '');
// Load schedule using domain hook
const { data: schedule, isLoading: scheduleLoading } = useLeagueAdminSchedule(leagueId, selectedSeasonId, !!isAdmin);
// Mutations
const publishMutation = useMutation({
mutationFn: async () => {
if (!schedule || !selectedSeasonId) return null;
return schedule.published
? await leagueService.unpublishAdminSchedule(leagueId, selectedSeasonId)
: await leagueService.publishAdminSchedule(leagueId, selectedSeasonId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
},
});
const saveMutation = useMutation({
mutationFn: async () => {
if (!selectedSeasonId || !scheduledAtIso) return null;
if (!editingRaceId) {
return await leagueService.createAdminScheduleRace(leagueId, selectedSeasonId, {
track,
car,
scheduledAtIso,
});
} else {
return await leagueService.updateAdminScheduleRace(leagueId, selectedSeasonId, editingRaceId, {
...(track ? { track } : {}),
...(car ? { car } : {}),
...(scheduledAtIso ? { scheduledAtIso } : {}),
});
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
// Reset form
setTrack('');
setCar('');
setScheduledAtIso('');
setEditingRaceId(null);
},
});
const deleteMutation = useMutation({
mutationFn: async (raceId: string) => {
return await leagueService.deleteAdminScheduleRace(leagueId, selectedSeasonId, raceId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
},
});
// Derived states
const isLoading = isAdminLoading || seasonsLoading || scheduleLoading;
const isPublishing = publishMutation.isPending;
const isSaving = saveMutation.isPending;
const isDeleting = deleteMutation.variables || null;
// Handlers
const handleSeasonChange = (newSeasonId: string) => {
setSeasonId(newSeasonId);
setEditingRaceId(null);
setTrack('');
setCar('');
setScheduledAtIso('');
};
const handlePublishToggle = () => {
publishMutation.mutate();
};
const handleAddOrSave = () => {
if (!scheduledAtIso) return;
saveMutation.mutate();
};
const handleEdit = (raceId: string) => {
if (!schedule) return;
const race = schedule.races.find((r) => r.id === raceId);
if (!race) return;
setEditingRaceId(raceId);
setTrack('');
setCar('');
setScheduledAtIso(race.scheduledAt.toISOString());
};
const handleDelete = (raceId: string) => {
const confirmed = window.confirm('Delete this race?');
if (!confirmed) return;
deleteMutation.mutate(raceId);
};
const handleCancelEdit = () => {
setEditingRaceId(null);
setTrack('');
setCar('');
setScheduledAtIso('');
};
// Prepare template data
const templateData = schedule && seasonsData && selectedSeasonId
? {
published: schedule.published,
races: schedule.races.map(r => ({
id: r.id,
name: r.name,
track: r.track || '',
car: r.car || '',
scheduledAt: r.scheduledAt.toISOString(),
})),
seasons: seasonsData.map(s => ({
seasonId: s.seasonId,
name: s.name,
})),
seasonId: selectedSeasonId,
}
: undefined;
// Render admin access required if not admin
if (!isLoading && !isAdmin) {
return (
<Stack gap={6}>
<Card>
<Box p={6} textAlign="center">
<Heading level={3}>Admin Access Required</Heading>
<Box mt={2}>
<Text size="sm" color="text-gray-400">Only league admins can manage the schedule.</Text>
</Box>
</Box>
</Card>
</Stack>
);
}
// Template component that wraps the actual template with all props
const TemplateWrapper = ({ data }: { data: typeof templateData }) => {
if (!data) return null;
return (
<LeagueAdminScheduleTemplate
viewData={data}
onSeasonChange={handleSeasonChange}
onPublishToggle={handlePublishToggle}
onAddOrSave={handleAddOrSave}
onEdit={handleEdit}
onDelete={handleDelete}
onCancelEdit={handleCancelEdit}
track={track}
car={car}
scheduledAtIso={scheduledAtIso}
editingRaceId={editingRaceId}
isPublishing={isPublishing}
isSaving={isSaving}
isDeleting={isDeleting}
setTrack={setTrack}
setCar={setCar}
setScheduledAtIso={setScheduledAtIso}
/>
);
};
return (
<PageWrapper
data={templateData}
isLoading={isLoading}
error={null}
Template={TemplateWrapper}
loading={{ variant: 'full-screen', message: 'Loading schedule admin...' }}
empty={{
title: 'No schedule data available',
description: 'Unable to load schedule administration data',
}}
/>
);
}

View File

@@ -1,205 +1,25 @@
'use client';
import { notFound } from 'next/navigation';
import { LeagueScheduleAdminPageQuery } from '@/lib/page-queries/LeagueScheduleAdminPageQuery';
import { LeagueAdminSchedulePageClient } from './LeagueAdminSchedulePageClient';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { LeagueAdminScheduleTemplate } from '@/templates/LeagueAdminScheduleTemplate';
import {
useLeagueAdminStatus,
useLeagueSeasons,
useLeagueAdminSchedule
} from "@/lib/hooks/league/useLeagueScheduleAdminPageData";
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
interface Props {
params: Promise<{ id: string }>;
}
export default function LeagueAdminSchedulePage() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId() || '';
const queryClient = useQueryClient();
export default async function Page({ params }: Props) {
const { id } = await params;
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
// Form state
const [seasonId, setSeasonId] = useState<string>('');
const [track, setTrack] = useState('');
const [car, setCar] = useState('');
const [scheduledAtIso, setScheduledAtIso] = useState('');
const [editingRaceId, setEditingRaceId] = useState<string | null>(null);
// Check admin status using domain hook
const { data: isAdmin, isLoading: isAdminLoading } = useLeagueAdminStatus(leagueId, currentDriverId);
// Load seasons using domain hook
const { data: seasonsData, isLoading: seasonsLoading } = useLeagueSeasons(leagueId, !!isAdmin);
// Auto-select season
const selectedSeasonId = seasonId || (seasonsData && seasonsData.length > 0
? (seasonsData.find((s) => s.status === 'active') ?? seasonsData[0])?.seasonId
: '');
// Load schedule using domain hook
const { data: schedule, isLoading: scheduleLoading, refetch: refetchSchedule } = useLeagueAdminSchedule(leagueId, selectedSeasonId, !!isAdmin);
// Mutations
const publishMutation = useMutation({
mutationFn: async () => {
if (!schedule || !selectedSeasonId) return null;
return schedule.published
? await leagueService.unpublishAdminSchedule(leagueId, selectedSeasonId)
: await leagueService.publishAdminSchedule(leagueId, selectedSeasonId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
},
});
const saveMutation = useMutation({
mutationFn: async () => {
if (!selectedSeasonId || !scheduledAtIso) return null;
if (!editingRaceId) {
return await leagueService.createAdminScheduleRace(leagueId, selectedSeasonId, {
track,
car,
scheduledAtIso,
});
} else {
return await leagueService.updateAdminScheduleRace(leagueId, selectedSeasonId, editingRaceId, {
...(track ? { track } : {}),
...(car ? { car } : {}),
...(scheduledAtIso ? { scheduledAtIso } : {}),
});
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
// Reset form
setTrack('');
setCar('');
setScheduledAtIso('');
setEditingRaceId(null);
},
});
const deleteMutation = useMutation({
mutationFn: async (raceId: string) => {
return await leagueService.deleteAdminScheduleRace(leagueId, selectedSeasonId, raceId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
},
});
// Derived states
const isLoading = isAdminLoading || seasonsLoading || scheduleLoading;
const isPublishing = publishMutation.isPending;
const isSaving = saveMutation.isPending;
const isDeleting = deleteMutation.variables || null;
// Handlers
const handleSeasonChange = (newSeasonId: string) => {
setSeasonId(newSeasonId);
setEditingRaceId(null);
setTrack('');
setCar('');
setScheduledAtIso('');
};
const handlePublishToggle = () => {
publishMutation.mutate();
};
const handleAddOrSave = () => {
if (!scheduledAtIso) return;
saveMutation.mutate();
};
const handleEdit = (raceId: string) => {
if (!schedule) return;
const race = schedule.races.find((r) => r.id === raceId);
if (!race) return;
setEditingRaceId(raceId);
setTrack('');
setCar('');
setScheduledAtIso(race.scheduledAt.toISOString());
};
const handleDelete = (raceId: string) => {
const confirmed = window.confirm('Delete this race?');
if (!confirmed) return;
deleteMutation.mutate(raceId);
};
const handleCancelEdit = () => {
setEditingRaceId(null);
setTrack('');
setCar('');
setScheduledAtIso('');
};
// Prepare template data
const templateData = schedule && seasonsData && selectedSeasonId
? {
schedule,
seasons: seasonsData,
seasonId: selectedSeasonId,
}
: undefined;
// Render admin access required if not admin
if (!isLoading && !isAdmin) {
return (
<div className="space-y-6">
<div className="bg-deep-graphite border border-charcoal-outline rounded-lg p-6 text-center">
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
<p className="text-sm text-gray-400">Only league admins can manage the schedule.</p>
</div>
</div>
);
// Execute the PageQuery
const result = await LeagueScheduleAdminPageQuery.execute({ leagueId: id });
if (result.isErr()) {
const error = result.getError();
if (error === 'notFound') {
notFound();
}
// For other errors, we still render the client component which handles its own loading/error states
// or we could render an error banner here.
}
// Template component that wraps the actual template with all props
const TemplateWrapper = ({ data }: { data: typeof templateData }) => {
if (!data) return null;
return (
<LeagueAdminScheduleTemplate
data={data}
onSeasonChange={handleSeasonChange}
onPublishToggle={handlePublishToggle}
onAddOrSave={handleAddOrSave}
onEdit={handleEdit}
onDelete={handleDelete}
onCancelEdit={handleCancelEdit}
track={track}
car={car}
scheduledAtIso={scheduledAtIso}
editingRaceId={editingRaceId}
isPublishing={isPublishing}
isSaving={isSaving}
isDeleting={isDeleting}
setTrack={setTrack}
setCar={setCar}
setScheduledAtIso={setScheduledAtIso}
/>
);
};
return (
<PageWrapper
data={templateData}
isLoading={isLoading}
error={null}
Template={TemplateWrapper}
loading={{ variant: 'full-screen', message: 'Loading schedule admin...' }}
empty={{
title: 'No schedule data available',
description: 'Unable to load schedule administration data',
}}
/>
);
}
return <LeagueAdminSchedulePageClient />;
}

View File

@@ -1,4 +1,4 @@
import { LeagueSchedulePageQuery } from '@/lib/page-queries/page-queries/LeagueSchedulePageQuery';
import { LeagueSchedulePageQuery } from '@/lib/page-queries/LeagueSchedulePageQuery';
import { LeagueScheduleTemplate } from '@/templates/LeagueScheduleTemplate';
import { notFound } from 'next/navigation';
@@ -17,7 +17,7 @@ export default async function LeagueSchedulePage({ params }: Props) {
if (result.isErr()) {
const error = result.getError();
if (error.type === 'notFound') {
if (error === 'notFound') {
notFound();
}
// For serverError, show the template with empty data

View File

@@ -1,4 +1,4 @@
import { LeagueSettingsPageQuery } from '@/lib/page-queries/page-queries/LeagueSettingsPageQuery';
import { LeagueSettingsPageQuery } from '@/lib/page-queries/LeagueSettingsPageQuery';
import { LeagueSettingsTemplate } from '@/templates/LeagueSettingsTemplate';
import { notFound } from 'next/navigation';
@@ -17,7 +17,7 @@ export default async function LeagueSettingsPage({ params }: Props) {
if (result.isErr()) {
const error = result.getError();
if (error.type === 'notFound') {
if (error === 'notFound') {
notFound();
}
// For serverError, show the template with empty data
@@ -29,8 +29,8 @@ export default async function LeagueSettingsPage({ params }: Props) {
description: 'League information unavailable',
visibility: 'private',
ownerId: 'unknown',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdAt: '1970-01-01T00:00:00Z',
updatedAt: '1970-01-01T00:00:00Z',
},
config: {
maxDrivers: 0,

View File

@@ -1,4 +1,4 @@
import { LeagueSponsorshipsPageQuery } from '@/lib/page-queries/page-queries/LeagueSponsorshipsPageQuery';
import { LeagueSponsorshipsPageQuery } from '@/lib/page-queries/LeagueSponsorshipsPageQuery';
import { LeagueSponsorshipsTemplate } from '@/templates/LeagueSponsorshipsTemplate';
import { notFound } from 'next/navigation';
@@ -17,12 +17,14 @@ export default async function LeagueSponsorshipsPage({ params }: Props) {
if (result.isErr()) {
const error = result.getError();
if (error.type === 'notFound') {
if (error === 'notFound') {
notFound();
}
// For serverError, show the template with empty data
return <LeagueSponsorshipsTemplate viewData={{
leagueId,
activeTab: 'overview',
onTabChange: () => {},
league: {
id: leagueId,
name: 'Unknown League',

View File

@@ -1,4 +1,4 @@
import { LeagueStandingsPageQuery } from '@/lib/page-queries/page-queries/LeagueStandingsPageQuery';
import { LeagueStandingsPageQuery } from '@/lib/page-queries/LeagueStandingsPageQuery';
import { LeagueStandingsTemplate } from '@/templates/LeagueStandingsTemplate';
import { notFound } from 'next/navigation';
@@ -17,7 +17,7 @@ export default async function Page({ params }: Props) {
if (result.isErr()) {
const error = result.getError();
if (error.type === 'notFound') {
if (error === 'notFound') {
notFound();
}
// For serverError, show the template with empty data

View File

@@ -0,0 +1,243 @@
'use client';
import { PenaltyFAB } from '@/ui/PenaltyFAB';
import { QuickPenaltyModal } from '@/components/leagues/QuickPenaltyModal';
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
import { StewardingStats } from '@/components/leagues/StewardingStats';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { useLeagueStewardingMutations } from "@/hooks/league/useLeagueStewardingMutations";
import { useMemo, useState } from 'react';
import { PendingProtestsList } from '@/components/leagues/PendingProtestsList';
import { PenaltyHistoryList } from '@/components/leagues/PenaltyHistoryList';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import type { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData';
import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel';
import { RaceViewModel } from '@/lib/view-models/RaceViewModel';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
interface StewardingTemplateProps {
data: StewardingViewData;
leagueId: string;
currentDriverId: string;
onRefetch: () => void;
}
export function StewardingPageClient({ data, leagueId, currentDriverId, onRefetch }: StewardingTemplateProps) {
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [selectedProtest, setSelectedProtest] = useState<ProtestViewModel | null>(null);
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
// Mutations using domain hook
const { acceptProtestMutation, rejectProtestMutation } = useLeagueStewardingMutations(onRefetch);
// Flatten protests for the specialized list components
const allPendingProtests = useMemo(() => {
return data.races.flatMap(r => r.pendingProtests.map(p => new ProtestViewModel({
id: p.id,
protestingDriverId: p.protestingDriverId,
accusedDriverId: p.accusedDriverId,
description: p.incident.description,
submittedAt: p.filedAt,
status: p.status,
raceId: r.id,
incident: p.incident,
proofVideoUrl: p.proofVideoUrl,
decisionNotes: p.decisionNotes,
} as never)));
}, [data.races]);
const allResolvedProtests = useMemo(() => {
return data.races.flatMap(r => r.resolvedProtests.map(p => new ProtestViewModel({
id: p.id,
protestingDriverId: p.protestingDriverId,
accusedDriverId: p.accusedDriverId,
description: p.incident.description,
submittedAt: p.filedAt,
status: p.status,
raceId: r.id,
incident: p.incident,
proofVideoUrl: p.proofVideoUrl,
decisionNotes: p.decisionNotes,
} as never)));
}, [data.races]);
const racesMap = useMemo(() => {
const map: Record<string, RaceViewModel> = {};
data.races.forEach(r => {
map[r.id] = new RaceViewModel({
id: r.id,
name: '',
date: r.scheduledAt,
track: r.track,
} as never);
});
return map;
}, [data.races]);
const driverMap = useMemo(() => {
const map: Record<string, DriverViewModel> = {};
data.drivers.forEach(d => {
map[d.id] = new DriverViewModel({
id: d.id,
name: d.name,
iracingId: '',
country: '',
joinedAt: '',
avatarUrl: null,
});
});
return map;
}, [data.drivers]);
const handleAcceptProtest = async (
protestId: string,
penaltyType: string,
penaltyValue: number,
stewardNotes: string
) => {
// Find the protest to get details for penalty
let foundProtest: { raceId: string; accusedDriverId: string; incident: { description: string } } | undefined;
data.races.forEach((raceData) => {
const p = raceData.pendingProtests.find((pr) => pr.id === protestId) ||
raceData.resolvedProtests.find((pr) => pr.id === protestId);
if (p) foundProtest = {
raceId: raceData.id,
accusedDriverId: p.accusedDriverId,
incident: { description: p.incident.description }
};
});
if (foundProtest) {
acceptProtestMutation.mutate({
protestId,
penaltyType,
penaltyValue,
stewardNotes,
raceId: foundProtest.raceId,
accusedDriverId: foundProtest.accusedDriverId,
reason: foundProtest.incident.description,
});
}
};
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
rejectProtestMutation.mutate({
protestId,
stewardNotes,
});
};
return (
<Stack gap={6}>
<Card>
<Box p={6}>
<Box display="flex" alignItems="center" justifyContent="between" mb={6}>
<Box>
<Heading level={2}>Stewarding</Heading>
<Box mt={1}>
<Text size="sm" color="text-gray-400">
Quick overview of protests and penalties across all races
</Text>
</Box>
</Box>
</Box>
{/* Stats summary */}
<StewardingStats
totalPending={data.totalPending}
totalResolved={data.totalResolved}
totalPenalties={data.totalPenalties}
/>
{/* Tab navigation */}
<Box borderBottom borderColor="border-charcoal-outline" mb={6}>
<Stack direction="row" gap={4}>
<Box
borderBottom={activeTab === 'pending'}
borderColor={activeTab === 'pending' ? 'border-primary-blue' : undefined}
>
<Button
variant="ghost"
onClick={() => setActiveTab('pending')}
rounded="none"
>
<Stack direction="row" align="center" gap={2}>
<Text weight="medium" color={activeTab === 'pending' ? 'text-primary-blue' : undefined}>Pending Protests</Text>
{data.totalPending > 0 && (
<Box px={2} py={0.5} fontSize="0.75rem" bg="bg-warning-amber/20" color="text-warning-amber" rounded="full">
{data.totalPending}
</Box>
)}
</Stack>
</Button>
</Box>
<Box
borderBottom={activeTab === 'history'}
borderColor={activeTab === 'history' ? 'border-primary-blue' : undefined}
>
<Button
variant="ghost"
onClick={() => setActiveTab('history')}
rounded="none"
>
<Text weight="medium" color={activeTab === 'history' ? 'text-primary-blue' : undefined}>History</Text>
</Button>
</Box>
</Stack>
</Box>
{/* Content */}
{activeTab === 'pending' ? (
<PendingProtestsList
protests={allPendingProtests}
races={racesMap}
drivers={driverMap}
leagueId={leagueId}
onReviewProtest={setSelectedProtest}
onProtestReviewed={onRefetch}
/>
) : (
<PenaltyHistoryList
protests={allResolvedProtests}
races={racesMap}
drivers={driverMap}
/>
)}
</Box>
</Card>
{activeTab === 'history' && (
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
)}
{selectedProtest && (
<ReviewProtestModal
protest={selectedProtest}
onClose={() => setSelectedProtest(null)}
onAccept={handleAcceptProtest}
onReject={handleRejectProtest}
/>
)}
{showQuickPenaltyModal && (
<QuickPenaltyModal
drivers={data.drivers.map(d => new DriverViewModel({
id: d.id,
name: d.name,
iracingId: '',
country: '',
joinedAt: '',
avatarUrl: null,
}))}
onClose={() => setShowQuickPenaltyModal(false)}
adminId={currentDriverId || ''}
races={data.races.map(r => ({ id: r.id, track: r.track, scheduledAt: new Date(r.scheduledAt) }))}
/>
)}
</Stack>
);
}

View File

@@ -1,197 +0,0 @@
'use client';
import { PenaltyFAB } from '@/ui/PenaltyFAB';
import { QuickPenaltyModal } from '@/components/leagues/QuickPenaltyModal';
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
import { StewardingStats } from '@/components/leagues/StewardingStats';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { useLeagueStewardingMutations } from "@/hooks/league/useLeagueStewardingMutations";
import {
AlertCircle,
AlertTriangle,
Calendar,
ChevronRight,
Flag,
Gavel,
MapPin,
Video
} from 'lucide-react';
import Link from 'next/link';
import { useMemo, useState } from 'react';
import { PendingProtestsList } from '@/components/leagues/PendingProtestsList';
import { PenaltyHistoryList } from '@/components/leagues/PenaltyHistoryList';
interface StewardingData {
totalPending: number;
totalResolved: number;
totalPenalties: number;
racesWithData: Array<{
race: { id: string; track: string; scheduledAt: Date; car?: string };
pendingProtests: any[];
resolvedProtests: any[];
penalties: any[];
}>;
allDrivers: any[];
driverMap: Record<string, any>;
}
interface StewardingTemplateProps {
data: StewardingData;
leagueId: string;
currentDriverId: string;
onRefetch: () => void;
}
export function StewardingTemplate({ data, leagueId, currentDriverId, onRefetch }: StewardingTemplateProps) {
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [selectedProtest, setSelectedProtest] = useState<any | null>(null);
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
// Mutations using domain hook
const { acceptProtestMutation, rejectProtestMutation } = useLeagueStewardingMutations(onRefetch);
// Flatten protests for the specialized list components
const allPendingProtests = useMemo(() => {
return data.racesWithData.flatMap(r => r.pendingProtests);
}, [data]);
const allResolvedProtests = useMemo(() => {
return data.racesWithData.flatMap(r => r.resolvedProtests);
}, [data]);
const racesMap = useMemo(() => {
const map: Record<string, any> = {};
data.racesWithData.forEach(r => {
map[r.race.id] = r.race;
});
return map;
}, [data]);
const handleAcceptProtest = async (
protestId: string,
penaltyType: string,
penaltyValue: number,
stewardNotes: string
) => {
// Find the protest to get details for penalty
let foundProtest: any | undefined;
data.racesWithData.forEach(raceData => {
const p = raceData.pendingProtests.find(pr => pr.id === protestId) ||
raceData.resolvedProtests.find(pr => pr.id === protestId);
if (p) foundProtest = { ...p, raceId: raceData.race.id };
});
if (foundProtest) {
acceptProtestMutation.mutate({
protestId,
penaltyType,
penaltyValue,
stewardNotes,
raceId: foundProtest.raceId,
accusedDriverId: foundProtest.accusedDriverId,
reason: foundProtest.incident.description,
});
}
};
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
rejectProtestMutation.mutate({
protestId,
stewardNotes,
});
};
return (
<div className="space-y-6">
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">Stewarding</h2>
<p className="text-sm text-gray-400 mt-1">
Quick overview of protests and penalties across all races
</p>
</div>
</div>
{/* Stats summary */}
<StewardingStats
totalPending={data.totalPending}
totalResolved={data.totalResolved}
totalPenalties={data.totalPenalties}
/>
{/* Tab navigation */}
<div className="border-b border-charcoal-outline mb-6">
<div className="flex gap-4">
<button
onClick={() => setActiveTab('pending')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'pending'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Pending Protests
{data.totalPending > 0 && (
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
{data.totalPending}
</span>
)}
</button>
<button
onClick={() => setActiveTab('history')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'history'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
History
</button>
</div>
</div>
{/* Content */}
{activeTab === 'pending' ? (
<PendingProtestsList
protests={allPendingProtests}
races={racesMap}
drivers={data.driverMap}
leagueId={leagueId}
onReviewProtest={setSelectedProtest}
onProtestReviewed={onRefetch}
/>
) : (
<PenaltyHistoryList
protests={allResolvedProtests}
races={racesMap}
drivers={data.driverMap}
/>
)}
</Card>
{activeTab === 'history' && (
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
)}
{selectedProtest && (
<ReviewProtestModal
protest={selectedProtest}
onClose={() => setSelectedProtest(null)}
onAccept={handleAcceptProtest}
onReject={handleRejectProtest}
/>
)}
{showQuickPenaltyModal && (
<QuickPenaltyModal
drivers={data.allDrivers}
onClose={() => setShowQuickPenaltyModal(false)}
adminId={currentDriverId || ''}
races={data.racesWithData.map(r => ({ id: r.race.id, track: r.race.track, scheduledAt: r.race.scheduledAt }))}
/>
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { LeagueStewardingPageQuery } from '@/lib/page-queries/page-queries/LeagueStewardingPageQuery';
import { StewardingTemplate } from '@/templates/StewardingTemplate';
import { LeagueStewardingPageQuery } from '@/lib/page-queries/LeagueStewardingPageQuery';
import { StewardingPageClient } from './StewardingPageClient';
import { notFound } from 'next/navigation';
interface Props {
@@ -17,19 +17,31 @@ export default async function LeagueStewardingPage({ params }: Props) {
if (result.isErr()) {
const error = result.getError();
if (error.type === 'notFound') {
if (error === 'notFound') {
notFound();
}
// For serverError, show the template with empty data
return <StewardingTemplate viewData={{
leagueId,
totalPending: 0,
totalResolved: 0,
totalPenalties: 0,
races: [],
drivers: []
}} />;
return <StewardingPageClient
leagueId={leagueId}
currentDriverId=""
onRefetch={() => {}}
data={{
leagueId,
totalPending: 0,
totalResolved: 0,
totalPenalties: 0,
races: [],
drivers: []
}}
/>;
}
return <StewardingTemplate viewData={result.unwrap()} />;
const data = result.unwrap();
return <StewardingPageClient
data={data}
leagueId={leagueId}
currentDriverId="" // Should be fetched or passed
onRefetch={() => {}} // Should be handled
/>;
}

View File

@@ -0,0 +1,808 @@
'use client';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { useInject } from '@/lib/di/hooks/useInject';
import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
import {
AlertTriangle,
ArrowLeft,
Calendar,
CheckCircle,
ChevronDown,
Clock,
ExternalLink,
Flag,
Gavel,
Grid3x3,
MapPin,
MessageCircle,
Send,
Shield,
ShieldAlert,
TrendingDown,
User,
Video,
XCircle,
AlertCircle,
type LucideIcon
} from 'lucide-react';
import { useParams, useRouter } from 'next/navigation';
import { useMemo, useState, useEffect } from 'react';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
import { useLeagueAdminStatus } from "@/hooks/league/useLeagueAdminStatus";
import { useProtestDetail } from "@/hooks/league/useProtestDetail";
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { Icon as UIIcon } from '@/ui/Icon';
import { Link as UILink } from '@/ui/Link';
import { routes } from '@/lib/routing/RouteConfig';
type PenaltyUiConfig = {
label: string;
description: string;
icon: LucideIcon;
color: string;
defaultValue?: number;
};
const PENALTY_UI: Record<string, PenaltyUiConfig> = {
time_penalty: {
label: 'Time Penalty',
description: 'Add seconds to race result',
icon: Clock,
color: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
defaultValue: 5,
},
grid_penalty: {
label: 'Grid Penalty',
description: 'Grid positions for next race',
icon: Grid3x3,
color: 'text-purple-400 bg-purple-500/10 border-purple-500/20',
defaultValue: 3,
},
points_deduction: {
label: 'Points Deduction',
description: 'Deduct championship points',
icon: TrendingDown,
color: 'text-red-400 bg-red-500/10 border-red-500/20',
defaultValue: 5,
},
disqualification: {
label: 'Disqualification',
description: 'Disqualify from race',
icon: XCircle,
color: 'text-red-500 bg-red-500/10 border-red-500/20',
defaultValue: 0,
},
warning: {
label: 'Warning',
description: 'Official warning only',
icon: AlertTriangle,
color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20',
defaultValue: 0,
},
license_points: {
label: 'License Points',
description: 'Safety rating penalty',
icon: ShieldAlert,
color: 'text-orange-400 bg-orange-500/10 border-orange-500/20',
defaultValue: 2,
},
};
export function ProtestDetailPageClient({ initialViewData }: { initialViewData: unknown }) {
const params = useParams();
const router = useRouter();
const leagueId = params.id as string;
const protestId = params.protestId as string;
const currentDriverId = useEffectiveDriverId();
const protestService = useInject(PROTEST_SERVICE_TOKEN);
// Decision state
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
const [penaltyType, setPenaltyType] = useState<string>('time_penalty');
const [penaltyValue, setPenaltyValue] = useState<number>(5);
const [stewardNotes, setStewardNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const [newComment, setNewComment] = useState('');
// Check admin status using hook
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId || '');
// Load protest detail using hook
const { data: detail, isLoading: detailLoading, error, retry } = useProtestDetail(leagueId, protestId, isAdmin || false);
// Use initial data if available
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const protestDetail = (detail || initialViewData) as any;
// Set initial penalty values when data loads
useEffect(() => {
if (protestDetail?.initialPenaltyType) {
setPenaltyType(protestDetail.initialPenaltyType);
setPenaltyValue(protestDetail.initialPenaltyValue);
}
}, [protestDetail]);
const penaltyTypes = useMemo(() => {
const referenceItems = protestDetail?.penaltyTypes ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return referenceItems.map((ref: any) => {
const ui = PENALTY_UI[ref.type] ?? {
icon: Gavel,
color: 'text-gray-400 bg-gray-500/10 border-gray-500/20',
};
return {
...ref,
icon: ui.icon,
color: ui.color,
};
});
}, [protestDetail?.penaltyTypes]);
const selectedPenalty = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return penaltyTypes.find((p: any) => p.type === penaltyType);
}, [penaltyTypes, penaltyType]);
const handleSubmitDecision = async () => {
if (!decision || !stewardNotes.trim() || !protestDetail || !currentDriverId) return;
setSubmitting(true);
try {
const protest = protestDetail.protest || protestDetail;
const defaultUpheldReason = protestDetail.defaultReasons?.upheld;
const defaultDismissedReason = protestDetail.defaultReasons?.dismissed;
if (decision === 'uphold') {
const requiresValue = selectedPenalty?.requiresValue ?? true;
const commandModel = new ProtestDecisionCommandModel({
decision,
penaltyType,
penaltyValue,
stewardNotes,
});
const options: {
requiresValue?: boolean;
defaultUpheldReason?: string;
defaultDismissedReason?: string;
} = { requiresValue };
if (defaultUpheldReason) {
options.defaultUpheldReason = defaultUpheldReason;
}
if (defaultDismissedReason) {
options.defaultDismissedReason = defaultDismissedReason;
}
const penaltyCommand = commandModel.toApplyPenaltyCommand(
protest.raceId || protestDetail.race?.id,
protest.accusedDriverId || protestDetail.accusedDriver?.id,
currentDriverId,
protest.id || protestDetail.protestId,
options,
);
const result = await protestService.applyPenalty(penaltyCommand);
if (result.isErr()) {
throw new Error(result.getError().message);
}
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const warningRef = protestDetail.penaltyTypes.find((p: any) => p.type === 'warning');
const requiresValue = warningRef?.requiresValue ?? false;
const commandModel = new ProtestDecisionCommandModel({
decision,
penaltyType: 'warning',
penaltyValue: 0,
stewardNotes,
});
const options: {
requiresValue?: boolean;
defaultUpheldReason?: string;
defaultDismissedReason?: string;
} = { requiresValue };
if (defaultUpheldReason) {
options.defaultUpheldReason = defaultUpheldReason;
}
if (defaultDismissedReason) {
options.defaultDismissedReason = defaultDismissedReason;
}
const penaltyCommand = commandModel.toApplyPenaltyCommand(
protest.raceId || protestDetail.race?.id,
protest.accusedDriverId || protestDetail.accusedDriver?.id,
currentDriverId,
protest.id || protestDetail.protestId,
options,
);
const result = await protestService.applyPenalty(penaltyCommand);
if (result.isErr()) {
throw new Error(result.getError().message);
}
}
router.push(routes.league.stewarding(leagueId));
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to submit decision');
} finally {
setSubmitting(false);
}
};
const handleRequestDefense = async () => {
if (!protestDetail || !currentDriverId) return;
try {
// Request defense
const result = await protestService.requestDefense({
protestId: protestDetail.protest?.id || protestDetail.protestId,
stewardId: currentDriverId,
});
if (result.isErr()) {
throw new Error(result.getError().message);
}
// Reload page to show updated status
window.location.reload();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to request defense');
}
};
const getStatusConfig = (status: string) => {
switch (status) {
case 'pending':
return { label: 'Pending Review', bg: 'bg-warning-amber/20', color: 'text-warning-amber', borderColor: 'border-warning-amber/30', icon: Clock };
case 'under_review':
return { label: 'Under Review', bg: 'bg-blue-500/20', color: 'text-blue-400', borderColor: 'border-blue-500/30', icon: Shield };
case 'awaiting_defense':
return { label: 'Awaiting Defense', bg: 'bg-purple-500/20', color: 'text-purple-400', borderColor: 'border-purple-500/30', icon: MessageCircle };
case 'upheld':
return { label: 'Upheld', bg: 'bg-red-500/20', color: 'text-red-400', borderColor: 'border-red-500/30', icon: CheckCircle };
case 'dismissed':
return { label: 'Dismissed', bg: 'bg-gray-500/20', color: 'text-gray-400', borderColor: 'border-gray-500/30', icon: XCircle };
default:
return { label: status, bg: 'bg-gray-500/20', color: 'text-gray-400', borderColor: 'border-gray-500/30', icon: AlertCircle };
}
};
// Show loading for admin check
if (adminLoading) {
return <LoadingWrapper variant="full-screen" message="Checking permissions..." />;
}
// Show access denied if not admin
if (!isAdmin) {
return (
<Card>
<Box p={12} textAlign="center">
<Box w={16} h={16} mx="auto" mb={4} rounded="full" bg="bg-iron-gray/50" display="flex" alignItems="center" justifyContent="center">
<UIIcon icon={AlertTriangle} size={8} color="text-warning-amber" />
</Box>
<Heading level={3}>Admin Access Required</Heading>
<Box mt={2}>
<Text size="sm" color="text-gray-400">
Only league admins can review protests.
</Text>
</Box>
</Box>
</Card>
);
}
return (
<StateContainer
data={protestDetail}
isLoading={detailLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading protest details...' },
error: { variant: 'full-screen' },
}}
>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{(pd: any) => {
if (!pd) return null;
const protest = pd.protest || pd;
const race = pd.race;
const protestingDriver = pd.protestingDriver;
const accusedDriver = pd.accusedDriver;
const statusConfig = getStatusConfig(protest.status);
const StatusIcon = statusConfig.icon;
const isPending = protest.status === 'pending';
const submittedAt = protest.submittedAt || pd.submittedAt;
const daysSinceFiled = Math.floor((Date.now() - new Date(submittedAt).getTime()) / (1000 * 60 * 60 * 24));
return (
<Box minHeight="100vh">
{/* Compact Header */}
<Box mb={6}>
<Stack direction="row" align="center" gap={3} mb={4}>
<UILink href={routes.league.stewarding(leagueId)}>
<UIIcon icon={ArrowLeft} size={5} color="text-gray-400" />
</UILink>
<Stack direction="row" align="center" gap={3} flexGrow={1}>
<Heading level={1}>Protest Review</Heading>
<Box display="flex" alignItems="center" gap={1.5} px={2.5} py={1} rounded="full" fontSize="0.75rem" weight="medium" border bg={statusConfig.bg} color={statusConfig.color} borderColor={statusConfig.borderColor}>
<UIIcon icon={StatusIcon} size={3} />
<Text>{statusConfig.label}</Text>
</Box>
{daysSinceFiled > 2 && isPending && (
<Box display="flex" alignItems="center" gap={1} px={2} py={0.5} fontSize="0.75rem" weight="medium" bg="bg-red-500/20" color="text-red-400" rounded="full">
<UIIcon icon={AlertTriangle} size={3} />
<Text>{daysSinceFiled}d old</Text>
</Box>
)}
</Stack>
</Stack>
</Box>
{/* Main Layout: Feed + Sidebar */}
<Grid cols={12} gap={6}>
{/* Left Sidebar - Incident Info */}
<GridItem colSpan={12} lgSpan={3}>
<Stack gap={4}>
{/* Drivers Involved */}
<Card>
<Box p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Parties Involved</Heading>
<Stack gap={3}>
{/* Protesting Driver */}
<UILink href={routes.driver.detail(protestingDriver?.id || '')} block>
<Box display="flex" alignItems="center" gap={3} p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-blue-500/50" hoverBg="bg-blue-500/5" transition cursor="pointer">
<Box w={10} h={10} rounded="full" bg="bg-blue-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<UIIcon icon={User} size={5} color="text-blue-400" />
</Box>
<Box flexGrow={1} minWidth={0}>
<Text size="xs" color="text-blue-400" weight="medium" block>Protesting</Text>
<Text size="sm" weight="semibold" color="text-white" truncate block>{protestingDriver?.name || 'Unknown'}</Text>
</Box>
<UIIcon icon={ExternalLink} size={3} color="text-gray-500" />
</Box>
</UILink>
{/* Accused Driver */}
<UILink href={routes.driver.detail(accusedDriver?.id || '')} block>
<Box display="flex" alignItems="center" gap={3} p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-orange-500/50" hoverBg="bg-orange-500/5" transition cursor="pointer">
<Box w={10} h={10} rounded="full" bg="bg-orange-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<UIIcon icon={User} size={5} color="text-orange-400" />
</Box>
<Box flexGrow={1} minWidth={0}>
<Text size="xs" color="text-orange-400" weight="medium" block>Accused</Text>
<Text size="sm" weight="semibold" color="text-white" truncate block>{accusedDriver?.name || 'Unknown'}</Text>
</Box>
<UIIcon icon={ExternalLink} size={3} color="text-gray-500" />
</Box>
</UILink>
</Stack>
</Box>
</Card>
{/* Race Info */}
<Card>
<Box p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Race Details</Heading>
<UILink
href={routes.race.detail(race?.id || '')}
block
mb={3}
>
<Box p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-primary-blue/50" hoverBg="bg-primary-blue/5" transition>
<Box display="flex" alignItems="center" justifyContent="between">
<Text size="sm" weight="medium" color="text-white">{race?.name || 'Unknown Race'}</Text>
<UIIcon icon={ExternalLink} size={3} color="text-gray-500" />
</Box>
</Box>
</UILink>
<Stack gap={2}>
<Box display="flex" alignItems="center" gap={2}>
<UIIcon icon={MapPin} size={4} color="text-gray-500" />
<Text size="sm" color="text-gray-300">{race?.name || 'Unknown Track'}</Text>
</Box>
<Box display="flex" alignItems="center" gap={2}>
<UIIcon icon={Calendar} size={4} color="text-gray-500" />
<Text size="sm" color="text-gray-300">{race?.formattedDate || (race?.scheduledAt ? new Date(race.scheduledAt).toLocaleDateString() : 'Unknown Date')}</Text>
</Box>
{protest.incident?.lap && (
<Box display="flex" alignItems="center" gap={2}>
<UIIcon icon={Flag} size={4} color="text-gray-500" />
<Text size="sm" color="text-gray-300">Lap {protest.incident.lap}</Text>
</Box>
)}
</Stack>
</Box>
</Card>
{protest.proofVideoUrl && (
<Card>
<Box p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Evidence</Heading>
<UILink
href={protest.proofVideoUrl}
target="_blank"
rel="noopener noreferrer"
block
>
<Box display="flex" alignItems="center" gap={2} p={3} rounded="lg" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" color="text-primary-blue" hoverBg="bg-primary-blue/20" transition>
<UIIcon icon={Video} size={4} />
<Text size="sm" weight="medium" flexGrow={1}>Watch Video</Text>
<UIIcon icon={ExternalLink} size={3} />
</Box>
</UILink>
</Box>
</Card>
)}
{/* Quick Stats */}
<Card>
<Box p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Timeline</Heading>
<Stack gap={2}>
<Box display="flex" justifyContent="between">
<Text size="sm" color="text-gray-500">Filed</Text>
<Text size="sm" color="text-gray-300">{new Date(submittedAt).toLocaleDateString()}</Text>
</Box>
<Box display="flex" justifyContent="between">
<Text size="sm" color="text-gray-500">Age</Text>
<Text size="sm" color={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</Text>
</Box>
{protest.reviewedAt && (
<Box display="flex" justifyContent="between">
<Text size="sm" color="text-gray-500">Resolved</Text>
<Text size="sm" color="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</Text>
</Box>
)}
</Stack>
</Box>
</Card>
</Stack>
</GridItem>
{/* Center - Discussion Feed */}
<GridItem colSpan={12} lgSpan={6}>
<Stack gap={4}>
{/* Timeline / Feed */}
<Card>
<Box borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/30" p={4}>
<Heading level={2}>Discussion</Heading>
</Box>
<Stack gap={0}>
{/* Initial Protest Filing */}
<Box p={4}>
<Box display="flex" gap={3}>
<Box w={10} h={10} rounded="full" bg="bg-blue-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<UIIcon icon={AlertCircle} size={5} color="text-blue-400" />
</Box>
<Box flexGrow={1} minWidth={0}>
<Box display="flex" alignItems="center" gap={2} mb={1}>
<Text weight="semibold" color="text-white" size="sm">{protestingDriver?.name || 'Unknown'}</Text>
<Text size="xs" color="text-blue-400" weight="medium">filed protest</Text>
<Text size="xs" color="text-gray-500"></Text>
<Text size="xs" color="text-gray-500">{new Date(submittedAt).toLocaleString()}</Text>
</Box>
<Box bg="bg-deep-graphite" rounded="lg" p={4} border borderColor="border-charcoal-outline">
<Text size="sm" color="text-gray-300" block mb={3}>{protest.description || pd.incident?.description}</Text>
{(protest.comment || pd.comment) && (
<Box mt={3} pt={3} borderTop borderColor="border-charcoal-outline/50">
<Text size="xs" color="text-gray-500" block mb={1}>Additional details:</Text>
<Text size="sm" color="text-gray-400">{protest.comment || pd.comment}</Text>
</Box>
)}
</Box>
</Box>
</Box>
</Box>
{/* Defense placeholder */}
{protest.status === 'awaiting_defense' && (
<Box p={4} bg="bg-purple-500/5">
<Box display="flex" gap={3}>
<Box w={10} h={10} rounded="full" bg="bg-purple-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<UIIcon icon={MessageCircle} size={5} color="text-purple-400" />
</Box>
<Box flexGrow={1}>
<Text size="sm" color="text-purple-400" weight="medium" block mb={1}>Defense Requested</Text>
<Text size="sm" color="text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</Text>
</Box>
</Box>
</Box>
)}
{/* Decision (if resolved) */}
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
<Box p={4} bg={protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}>
<Box display="flex" gap={3}>
<Box w={10} h={10} rounded="full" display="flex" alignItems="center" justifyContent="center" flexShrink={0} bg={protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'}>
<UIIcon icon={Gavel} size={5} color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'} />
</Box>
<Box flexGrow={1} minWidth={0}>
<Box display="flex" alignItems="center" gap={2} mb={1}>
<Text weight="semibold" color="text-white" size="sm">Steward Decision</Text>
<Text size="xs" weight="medium" color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}>
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
</Text>
{protest.reviewedAt && (
<>
<Text size="xs" color="text-gray-500"></Text>
<Text size="xs" color="text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</Text>
</>
)}
</Box>
<Box rounded="lg" p={4} border bg={protest.status === 'upheld' ? 'bg-red-500/10' : 'bg-gray-500/10'} borderColor={protest.status === 'upheld' ? 'border-red-500/20' : 'border-gray-500/20'}>
<Text size="sm" color="text-gray-300">{protest.decisionNotes}</Text>
</Box>
</Box>
</Box>
</Box>
)}
</Stack>
{/* Add Comment */}
{isPending && (
<Box p={4} borderTop borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
<Box display="flex" gap={3}>
<Box w={10} h={10} rounded="full" bg="bg-iron-gray" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<UIIcon icon={User} size={5} color="text-gray-500" />
</Box>
<Box flexGrow={1}>
<Box as="textarea"
value={newComment}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewComment(e.target.value)}
placeholder="Add a comment or request more information..."
rows={2}
w="full"
px={4}
py={3}
bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="lg"
color="text-white"
fontSize="sm"
/>
<Box display="flex" justifyContent="end" mt={2}>
<Button variant="secondary" disabled={!newComment.trim()}>
<UIIcon icon={Send} size={3} mr={1} />
Comment
</Button>
</Box>
</Box>
</Box>
</Box>
)}
</Card>
</Stack>
</GridItem>
{/* Right Sidebar - Actions */}
<GridItem colSpan={12} lgSpan={3}>
<Stack gap={4}>
{isPending && (
<>
{/* Quick Actions */}
<Card>
<Box p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Actions</Heading>
<Stack gap={2}>
<Button
variant="secondary"
fullWidth
onClick={handleRequestDefense}
>
<Stack direction="row" align="center" gap={2}>
<UIIcon icon={MessageCircle} size={4} />
<Text>Request Defense</Text>
</Stack>
</Button>
<Button
variant="primary"
fullWidth
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
>
<Stack direction="row" align="center" gap={2} fullWidth>
<UIIcon icon={Gavel} size={4} />
<Text>Make Decision</Text>
<Box ml="auto" transition transform={showDecisionPanel ? 'rotate(180deg)' : 'none'}>
<UIIcon icon={ChevronDown} size={4} />
</Box>
</Stack>
</Button>
</Stack>
</Box>
</Card>
{/* Decision Panel */}
{showDecisionPanel && (
<Card>
<Box p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Stewarding Decision</Heading>
{/* Decision Selection */}
<Grid cols={2} gap={2} mb={4}>
<Button
variant="ghost"
onClick={() => setDecision('uphold')}
p={3}
border
borderColor={decision === 'uphold' ? 'border-racing-red' : 'border-charcoal-outline'}
bg={decision === 'uphold' ? 'bg-racing-red/10' : 'transparent'}
>
<Stack align="center" gap={1}>
<UIIcon icon={CheckCircle} size={5} color={decision === 'uphold' ? 'text-red-400' : 'text-gray-500'} />
<Text size="xs" weight="medium" color={decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}>Uphold</Text>
</Stack>
</Button>
<Button
variant="ghost"
onClick={() => setDecision('dismiss')}
p={3}
border
borderColor={decision === 'dismiss' ? 'border-gray-500' : 'border-charcoal-outline'}
bg={decision === 'dismiss' ? 'bg-gray-500/10' : 'transparent'}
>
<Stack align="center" gap={1}>
<UIIcon icon={XCircle} size={5} color={decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'} />
<Text size="xs" weight="medium" color={decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}>Dismiss</Text>
</Stack>
</Button>
</Grid>
{/* Penalty Selection (if upholding) */}
{decision === 'uphold' && (
<Box mb={4}>
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={2}>Penalty Type</Text>
{penaltyTypes.length === 0 ? (
<Text size="xs" color="text-gray-500">
Loading penalty types...
</Text>
) : (
<>
<Grid cols={2} gap={2}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{penaltyTypes.map((penalty: any) => {
const Icon = penalty.icon;
const isSelected = penaltyType === penalty.type;
return (
<Button
key={penalty.type}
variant="ghost"
onClick={() => {
setPenaltyType(penalty.type);
setPenaltyValue(penalty.defaultValue);
}}
p={2}
border
borderColor={isSelected ? undefined : 'border-charcoal-outline'}
bg={isSelected ? undefined : 'bg-iron-gray/30'}
color={isSelected ? penalty.color : undefined}
title={penalty.description}
>
<Stack align="start" gap={0.5}>
<UIIcon icon={Icon} size={3.5} color={isSelected ? undefined : 'text-gray-500'} />
<Text size="xs" weight="medium" fontSize="10px" color={isSelected ? undefined : 'text-gray-500'}>
{penalty.label}
</Text>
</Stack>
</Button>
);
})}
</Grid>
{selectedPenalty?.requiresValue && (
<Box mt={3}>
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1}>
Value ({selectedPenalty.valueLabel})
</Text>
<Box as="input"
type="number"
value={penaltyValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPenaltyValue(Number(e.target.value))}
min="1"
w="full"
px={3}
py={2}
bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="lg"
color="text-white"
fontSize="sm"
/>
</Box>
)}
</>
)}
</Box>
)}
{/* Steward Notes */}
<Box mb={4}>
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1}>Decision Reasoning *</Text>
<Box as="textarea"
value={stewardNotes}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setStewardNotes(e.target.value)}
placeholder="Explain your decision..."
rows={4}
w="full"
px={3}
py={2}
bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="lg"
color="text-white"
fontSize="sm"
/>
</Box>
{/* Submit */}
<Button
variant="primary"
fullWidth
onClick={handleSubmitDecision}
disabled={!decision || !stewardNotes.trim() || submitting}
>
{submitting ? 'Submitting...' : 'Submit Decision'}
</Button>
</Box>
</Card>
)}
</>
)}
{/* Already Resolved Info */}
{!isPending && (
<Card>
<Box p={4} textAlign="center">
<Box py={4} color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}>
<UIIcon icon={Gavel} size={8} mx="auto" mb={2} />
<Text weight="semibold" block>Case Closed</Text>
<Text size="xs" color="text-gray-500" block mt={1}>
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
</Text>
</Box>
</Box>
</Card>
)}
</Stack>
</GridItem>
</Grid>
</Box>
);
}}
</StateContainer>
);
}

View File

@@ -1,737 +1,25 @@
'use client';
import { notFound } from 'next/navigation';
import { LeagueProtestDetailPageQuery } from '@/lib/page-queries/LeagueProtestDetailPageQuery';
import { ProtestDetailPageClient } from './ProtestDetailPageClient';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useInject } from '@/lib/di/hooks/useInject';
import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { ProtestDetailViewModel } from '@/lib/view-models/ProtestDetailViewModel';
import { ProtestDriverViewModel } from '@/lib/view-models/ProtestDriverViewModel';
import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
import {
AlertCircle,
AlertTriangle,
ArrowLeft,
Calendar,
CheckCircle,
ChevronDown,
Clock,
ExternalLink,
Flag,
Gavel,
Grid3x3,
MapPin,
MessageCircle,
Send,
Shield,
ShieldAlert,
TrendingDown,
User,
Video,
XCircle
} from 'lucide-react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
import { useLeagueAdminStatus } from "@/lib/hooks/league/useLeagueAdminStatus";
import { useProtestDetail } from "@/lib/hooks/league/useProtestDetail";
// Timeline event types
interface TimelineEvent {
id: string;
type: 'protest_filed' | 'defense_requested' | 'defense_submitted' | 'steward_comment' | 'decision' | 'penalty_applied';
timestamp: Date;
actor: ProtestDriverViewModel | null;
content: string;
metadata?: Record<string, unknown>;
interface Props {
params: Promise<{ id: string; protestId: string }>;
}
type PenaltyUiConfig = {
label: string;
description: string;
icon: typeof Gavel;
color: string;
defaultValue?: number;
};
const PENALTY_UI: Record<string, PenaltyUiConfig> = {
time_penalty: {
label: 'Time Penalty',
description: 'Add seconds to race result',
icon: Clock,
color: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
defaultValue: 5,
},
grid_penalty: {
label: 'Grid Penalty',
description: 'Grid positions for next race',
icon: Grid3x3,
color: 'text-purple-400 bg-purple-500/10 border-purple-500/20',
defaultValue: 3,
},
points_deduction: {
label: 'Points Deduction',
description: 'Deduct championship points',
icon: TrendingDown,
color: 'text-red-400 bg-red-500/10 border-red-500/20',
defaultValue: 5,
},
disqualification: {
label: 'Disqualification',
description: 'Disqualify from race',
icon: XCircle,
color: 'text-red-500 bg-red-500/10 border-red-500/20',
defaultValue: 0,
},
warning: {
label: 'Warning',
description: 'Official warning only',
icon: AlertTriangle,
color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20',
defaultValue: 0,
},
license_points: {
label: 'License Points',
description: 'Safety rating penalty',
icon: ShieldAlert,
color: 'text-orange-400 bg-orange-500/10 border-orange-500/20',
defaultValue: 2,
},
};
export default function ProtestReviewPage() {
const params = useParams();
const router = useRouter();
const leagueId = params.id as string;
const protestId = params.protestId as string;
const currentDriverId = useEffectiveDriverId();
const protestService = useInject(PROTEST_SERVICE_TOKEN);
// Decision state
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
const [penaltyType, setPenaltyType] = useState<string>('time_penalty');
const [penaltyValue, setPenaltyValue] = useState<number>(5);
const [stewardNotes, setStewardNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const [newComment, setNewComment] = useState('');
// Check admin status using hook
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId || '');
// Load protest detail using hook
const { data: detail, isLoading: detailLoading, error, retry } = useProtestDetail(leagueId, protestId, isAdmin || false);
// Set initial penalty values when data loads
useMemo(() => {
if (detail?.initialPenaltyType) {
setPenaltyType(detail.initialPenaltyType);
setPenaltyValue(detail.initialPenaltyValue);
export default async function Page({ params }: Props) {
const { id, protestId } = await params;
// Execute the PageQuery
const result = await LeagueProtestDetailPageQuery.execute({ leagueId: id, protestId });
if (result.isErr()) {
const error = result.getError();
if (error === 'notFound') {
notFound();
}
}, [detail]);
const penaltyTypes = useMemo(() => {
const referenceItems = detail?.penaltyTypes ?? [];
return referenceItems.map((ref) => {
const ui = PENALTY_UI[ref.type] ?? {
icon: Gavel,
color: 'text-gray-400 bg-gray-500/10 border-gray-500/20',
};
return {
...ref,
icon: ui.icon,
color: ui.color,
};
});
}, [detail?.penaltyTypes]);
const selectedPenalty = useMemo(() => {
return penaltyTypes.find((p) => p.type === penaltyType);
}, [penaltyTypes, penaltyType]);
const handleSubmitDecision = async () => {
if (!decision || !stewardNotes.trim() || !detail || !currentDriverId) return;
setSubmitting(true);
try {
const protest = detail.protest;
const defaultUpheldReason = detail.defaultReasons?.upheld;
const defaultDismissedReason = detail.defaultReasons?.dismissed;
if (decision === 'uphold') {
const requiresValue = selectedPenalty?.requiresValue ?? true;
const commandModel = new ProtestDecisionCommandModel({
decision,
penaltyType,
penaltyValue,
stewardNotes,
});
const options: {
requiresValue?: boolean;
defaultUpheldReason?: string;
defaultDismissedReason?: string;
} = { requiresValue };
if (defaultUpheldReason) {
options.defaultUpheldReason = defaultUpheldReason;
}
if (defaultDismissedReason) {
options.defaultDismissedReason = defaultDismissedReason;
}
const penaltyCommand = commandModel.toApplyPenaltyCommand(
protest.raceId,
protest.accusedDriverId,
currentDriverId,
protest.id,
options,
);
await protestService.applyPenalty(penaltyCommand);
} else {
const warningRef = detail.penaltyTypes.find((p) => p.type === 'warning');
const requiresValue = warningRef?.requiresValue ?? false;
const commandModel = new ProtestDecisionCommandModel({
decision,
penaltyType: 'warning',
penaltyValue: 0,
stewardNotes,
});
const options: {
requiresValue?: boolean;
defaultUpheldReason?: string;
defaultDismissedReason?: string;
} = { requiresValue };
if (defaultUpheldReason) {
options.defaultUpheldReason = defaultUpheldReason;
}
if (defaultDismissedReason) {
options.defaultDismissedReason = defaultDismissedReason;
}
const penaltyCommand = commandModel.toApplyPenaltyCommand(
protest.raceId,
protest.accusedDriverId,
currentDriverId,
protest.id,
options,
);
await protestService.applyPenalty(penaltyCommand);
}
router.push(`/leagues/${leagueId}/stewarding`);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to submit decision');
} finally {
setSubmitting(false);
}
};
const handleRequestDefense = async () => {
if (!detail || !currentDriverId) return;
try {
// Request defense
await protestService.requestDefense({
protestId: detail.protest.id,
stewardId: currentDriverId,
});
// Reload page to show updated status
window.location.reload();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to request defense');
}
};
const getStatusConfig = (status: string) => {
switch (status) {
case 'pending':
return { label: 'Pending Review', color: 'bg-warning-amber/20 text-warning-amber border-warning-amber/30', icon: Clock };
case 'under_review':
return { label: 'Under Review', color: 'bg-blue-500/20 text-blue-400 border-blue-500/30', icon: Shield };
case 'awaiting_defense':
return { label: 'Awaiting Defense', color: 'bg-purple-500/20 text-purple-400 border-purple-500/30', icon: MessageCircle };
case 'upheld':
return { label: 'Upheld', color: 'bg-red-500/20 text-red-400 border-red-500/30', icon: CheckCircle };
case 'dismissed':
return { label: 'Dismissed', color: 'bg-gray-500/20 text-gray-400 border-gray-500/30', icon: XCircle };
default:
return { label: status, color: 'bg-gray-500/20 text-gray-400 border-gray-500/30', icon: AlertCircle };
}
};
// Show loading for admin check
if (adminLoading) {
return <LoadingWrapper variant="full-screen" message="Checking permissions..." />;
}
// Show access denied if not admin
if (!isAdmin) {
return (
<Card>
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
<AlertTriangle className="w-8 h-8 text-warning-amber" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
<p className="text-sm text-gray-400">
Only league admins can review protests.
</p>
</div>
</Card>
);
}
const viewData = result.isOk() ? result.unwrap() : null;
return (
<StateContainer
data={detail}
isLoading={detailLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading protest details...' },
error: { variant: 'full-screen' },
}}
>
{(protestDetail) => {
if (!protestDetail) return null;
const protest = protestDetail.protest;
const race = protestDetail.race;
const protestingDriver = protestDetail.protestingDriver;
const accusedDriver = protestDetail.accusedDriver;
const statusConfig = getStatusConfig(protest.status);
const StatusIcon = statusConfig.icon;
const isPending = protest.status === 'pending';
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.submittedAt).getTime()) / (1000 * 60 * 60 * 24));
return (
<div className="min-h-screen">
{/* Compact Header */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-4">
<Link href={`/leagues/${leagueId}/stewarding`} className="text-gray-400 hover:text-white transition-colors">
<ArrowLeft className="h-5 w-5" />
</Link>
<div className="flex-1 flex items-center gap-3">
<h1 className="text-xl font-bold text-white">Protest Review</h1>
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${statusConfig.color}`}>
<StatusIcon className="w-3 h-3" />
{statusConfig.label}
</div>
{daysSinceFiled > 2 && isPending && (
<span className="flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
<AlertTriangle className="w-3 h-3" />
{daysSinceFiled}d old
</span>
)}
</div>
</div>
</div>
{/* Main Layout: Feed + Sidebar */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Left Sidebar - Incident Info */}
<div className="lg:col-span-3 space-y-4">
{/* Drivers Involved */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Parties Involved</h3>
<div className="space-y-3">
{/* Protesting Driver */}
<Link href={`/drivers/${protestingDriver?.id || ''}`} className="block">
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-blue-500/50 hover:bg-blue-500/5 transition-colors cursor-pointer">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-blue-400 font-medium">Protesting</p>
<p className="text-sm font-semibold text-white truncate">{protestingDriver?.name || 'Unknown'}</p>
</div>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
{/* Accused Driver */}
<Link href={`/drivers/${accusedDriver?.id || ''}`} className="block">
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-orange-500/50 hover:bg-orange-500/5 transition-colors cursor-pointer">
<div className="w-10 h-10 rounded-full bg-orange-500/20 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-orange-400 font-medium">Accused</p>
<p className="text-sm font-semibold text-white truncate">{accusedDriver?.name || 'Unknown'}</p>
</div>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
</div>
</Card>
{/* Race Info */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Race Details</h3>
<Link
href={`/races/${race.id}`}
className="block mb-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/50 hover:bg-primary-blue/5 transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-white">{race.name}</span>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<MapPin className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">{race.name}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Calendar className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">{race.formattedDate}</span>
</div>
{protest.incident?.lap && (
<div className="flex items-center gap-2 text-sm">
<Flag className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">Lap {protest.incident.lap}</span>
</div>
)}
</div>
</Card>
{protest.proofVideoUrl && (
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Evidence</h3>
<a
href={protest.proofVideoUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-3 rounded-lg bg-primary-blue/10 border border-primary-blue/20 text-primary-blue hover:bg-primary-blue/20 transition-colors"
>
<Video className="w-4 h-4" />
<span className="text-sm font-medium flex-1">Watch Video</span>
<ExternalLink className="w-3 h-3" />
</a>
</Card>
)}
{/* Quick Stats */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Timeline</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Filed</span>
<span className="text-gray-300">{new Date(protest.submittedAt).toLocaleDateString()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Age</span>
<span className={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</span>
</div>
{protest.reviewedAt && (
<div className="flex justify-between">
<span className="text-gray-500">Resolved</span>
<span className="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</span>
</div>
)}
</div>
</Card>
</div>
{/* Center - Discussion Feed */}
<div className="lg:col-span-6 space-y-4">
{/* Timeline / Feed */}
<Card className="p-0 overflow-hidden">
<div className="p-4 border-b border-charcoal-outline bg-iron-gray/30">
<h2 className="text-sm font-semibold text-white">Discussion</h2>
</div>
<div className="divide-y divide-charcoal-outline/50">
{/* Initial Protest Filing */}
<div className="p-4">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
<AlertCircle className="w-5 h-5 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-white text-sm">{protestingDriver?.name || 'Unknown'}</span>
<span className="text-xs text-blue-400 font-medium">filed protest</span>
<span className="text-xs text-gray-500"></span>
<span className="text-xs text-gray-500">{new Date(protest.submittedAt).toLocaleString()}</span>
</div>
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
<p className="text-sm text-gray-300 mb-3">{protest.description}</p>
{protest.comment && (
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
<p className="text-xs text-gray-500 mb-1">Additional details:</p>
<p className="text-sm text-gray-400">{protest.comment}</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* Defense placeholder - will be populated when defense system is implemented */}
{protest.status === 'awaiting_defense' && (
<div className="p-4 bg-purple-500/5">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center flex-shrink-0">
<MessageCircle className="w-5 h-5 text-purple-400" />
</div>
<div className="flex-1">
<p className="text-sm text-purple-400 font-medium mb-1">Defense Requested</p>
<p className="text-sm text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</p>
</div>
</div>
</div>
)}
{/* Decision (if resolved) */}
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
<div className={`p-4 ${protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}`}>
<div className="flex gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'
}`}>
<Gavel className={`w-5 h-5 ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-white text-sm">Steward Decision</span>
<span className={`text-xs font-medium ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`}>
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
</span>
{protest.reviewedAt && (
<>
<span className="text-xs text-gray-500"></span>
<span className="text-xs text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</span>
</>
)}
</div>
<div className={`rounded-lg p-4 border ${
protest.status === 'upheld'
? 'bg-red-500/10 border-red-500/20'
: 'bg-gray-500/10 border-gray-500/20'
}`}>
<p className="text-sm text-gray-300">{protest.decisionNotes}</p>
</div>
</div>
</div>
</div>
)}
</div>
{/* Add Comment (future feature) */}
{isPending && (
<div className="p-4 border-t border-charcoal-outline bg-iron-gray/20">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-gray-500" />
</div>
<div className="flex-1">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Add a comment or request more information..."
rows={2}
className="w-full px-4 py-3 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue text-sm resize-none"
/>
<div className="flex justify-end mt-2">
<Button variant="secondary" disabled={!newComment.trim()}>
<Send className="w-3 h-3 mr-1" />
Comment
</Button>
</div>
</div>
</div>
</div>
)}
</Card>
</div>
{/* Right Sidebar - Actions */}
<div className="lg:col-span-3 space-y-4">
{isPending && (
<>
{/* Quick Actions */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Actions</h3>
<div className="space-y-2">
<Button
variant="secondary"
className="w-full justify-start"
onClick={handleRequestDefense}
>
<MessageCircle className="w-4 h-4 mr-2" />
Request Defense
</Button>
<Button
variant="primary"
className="w-full justify-start"
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
>
<Gavel className="w-4 h-4 mr-2" />
Make Decision
<ChevronDown className={`w-4 h-4 ml-auto transition-transform ${showDecisionPanel ? 'rotate-180' : ''}`} />
</Button>
</div>
</Card>
{/* Decision Panel */}
{showDecisionPanel && (
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Stewarding Decision</h3>
{/* Decision Selection */}
<div className="grid grid-cols-2 gap-2 mb-4">
<button
onClick={() => setDecision('uphold')}
className={`p-3 rounded-lg border-2 transition-all ${
decision === 'uphold'
? 'border-red-500 bg-red-500/10'
: 'border-charcoal-outline hover:border-gray-600'
}`}
>
<CheckCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'uphold' ? 'text-red-400' : 'text-gray-500'}`} />
<p className={`text-xs font-medium ${decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}`}>Uphold</p>
</button>
<button
onClick={() => setDecision('dismiss')}
className={`p-3 rounded-lg border-2 transition-all ${
decision === 'dismiss'
? 'border-gray-500 bg-gray-500/10'
: 'border-charcoal-outline hover:border-gray-600'
}`}
>
<XCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'}`} />
<p className={`text-xs font-medium ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}`}>Dismiss</p>
</button>
</div>
{/* Penalty Selection (if upholding) */}
{decision === 'uphold' && (
<div className="mb-4">
<label className="text-xs font-medium text-gray-400 mb-2 block">Penalty Type</label>
{penaltyTypes.length === 0 ? (
<div className="text-xs text-gray-500">
Loading penalty types...
</div>
) : (
<>
<div className="grid grid-cols-2 gap-1.5">
{penaltyTypes.map((penalty) => {
const Icon = penalty.icon;
const isSelected = penaltyType === penalty.type;
return (
<button
key={penalty.type}
onClick={() => {
setPenaltyType(penalty.type);
setPenaltyValue(penalty.defaultValue);
}}
className={`p-2 rounded-lg border transition-all text-left ${
isSelected
? `${penalty.color} border`
: 'border-charcoal-outline hover:border-gray-600 bg-iron-gray/30'
}`}
title={penalty.description}
>
<Icon className={`h-3.5 w-3.5 mb-0.5 ${isSelected ? '' : 'text-gray-500'}`} />
<p className={`text-[10px] font-medium leading-tight ${isSelected ? '' : 'text-gray-500'}`}>
{penalty.label}
</p>
</button>
);
})}
</div>
{selectedPenalty?.requiresValue && (
<div className="mt-3">
<label className="text-xs font-medium text-gray-400 mb-1 block">
Value ({selectedPenalty.valueLabel})
</label>
<input
type="number"
value={penaltyValue}
onChange={(e) => setPenaltyValue(Number(e.target.value))}
min="1"
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:border-primary-blue"
/>
</div>
)}
</>
)}
</div>
)}
{/* Steward Notes */}
<div className="mb-4">
<label className="text-xs font-medium text-gray-400 mb-1 block">Decision Reasoning *</label>
<textarea
value={stewardNotes}
onChange={(e) => setStewardNotes(e.target.value)}
placeholder="Explain your decision..."
rows={4}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 text-sm focus:outline-none focus:border-primary-blue resize-none"
/>
</div>
{/* Submit */}
<Button
variant="primary"
className="w-full"
onClick={handleSubmitDecision}
disabled={!decision || !stewardNotes.trim() || submitting}
>
{submitting ? 'Submitting...' : 'Submit Decision'}
</Button>
</Card>
)}
</>
)}
{/* Already Resolved Info */}
{!isPending && (
<Card className="p-4">
<div className={`text-center py-4 ${
protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'
}`}>
<Gavel className="w-8 h-8 mx-auto mb-2" />
<p className="font-semibold">Case Closed</p>
<p className="text-xs text-gray-500 mt-1">
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
</p>
</div>
</Card>
)}
</div>
</div>
</div>
);
}}
</StateContainer>
);
return <ProtestDetailPageClient initialViewData={viewData} />;
}

View File

@@ -0,0 +1,374 @@
'use client';
import React, { useState, useMemo } from 'react';
import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import { TransactionRow } from '@/components/leagues/TransactionRow';
import type { LeagueWalletViewData } from '@/lib/view-data/leagues/LeagueWalletViewData';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Container } from '@/ui/Container';
import { Grid } from '@/ui/Grid';
import { Icon as UIIcon } from '@/ui/Icon';
import {
Wallet,
DollarSign,
ArrowUpRight,
Clock,
AlertTriangle,
Download,
TrendingUp
} from 'lucide-react';
interface WalletTemplateProps {
viewData: LeagueWalletViewData;
onWithdraw?: (amount: number) => void;
onExport?: () => void;
mutationLoading?: boolean;
}
export function LeagueWalletPageClient({ viewData, onWithdraw, onExport, mutationLoading = false }: WalletTemplateProps) {
const [withdrawAmount, setWithdrawAmount] = useState('');
const [showWithdrawModal, setShowWithdrawModal] = useState(false);
const [filterType, setFilterType] = useState<'all' | 'sponsorship' | 'membership' | 'withdrawal' | 'prize'>('all');
const filteredTransactions = useMemo(() => {
if (filterType === 'all') return viewData.transactions;
return viewData.transactions.filter(t => t.type === filterType);
}, [viewData.transactions, filterType]);
const handleWithdrawClick = () => {
const amount = parseFloat(withdrawAmount);
if (!amount || amount <= 0) return;
if (onWithdraw) {
onWithdraw(amount);
setShowWithdrawModal(false);
setWithdrawAmount('');
}
};
const canWithdraw = viewData.balance > 0;
const withdrawalBlockReason = !canWithdraw ? 'Balance is zero' : undefined;
return (
<Container size="lg" py={8}>
{/* Header */}
<Box display="flex" alignItems="center" justifyContent="between" mb={8}>
<Box>
<Heading level={1}>League Wallet</Heading>
<Text color="text-gray-400">Manage your league&apos;s finances and payouts</Text>
</Box>
<Stack direction="row" align="center" gap={2}>
<Button variant="secondary" onClick={onExport}>
<Stack direction="row" align="center" gap={2}>
<UIIcon icon={Download} size={4} />
<Text>Export</Text>
</Stack>
</Button>
<Button
variant="primary"
onClick={() => setShowWithdrawModal(true)}
disabled={!canWithdraw || !onWithdraw}
>
<Stack direction="row" align="center" gap={2}>
<UIIcon icon={ArrowUpRight} size={4} />
<Text>Withdraw</Text>
</Stack>
</Button>
</Stack>
</Box>
{/* Withdrawal Warning */}
{!canWithdraw && withdrawalBlockReason && (
<Box mb={6} p={4} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30">
<Stack direction="row" align="start" gap={3}>
<UIIcon icon={AlertTriangle} size={5} color="text-warning-amber" flexShrink={0} mt={0.5} />
<Box>
<Text weight="medium" color="text-warning-amber" block>Withdrawals Temporarily Unavailable</Text>
<Text size="sm" color="text-gray-400" block mt={1}>{withdrawalBlockReason}</Text>
</Box>
</Stack>
</Box>
)}
{/* Stats Grid */}
<Grid cols={1} mdCols={2} lgCols={4} gap={4} mb={8}>
<Card>
<Box p={4}>
<Stack direction="row" align="center" gap={3}>
<Box display="flex" h={12} w={12} alignItems="center" justifyContent="center" rounded="xl" bg="bg-performance-green/10">
<UIIcon icon={Wallet} size={6} color="text-performance-green" />
</Box>
<Box>
<Text size="2xl" weight="bold" color="text-white" block>{viewData.formattedBalance}</Text>
<Text size="sm" color="text-gray-400" block>Available Balance</Text>
</Box>
</Stack>
</Box>
</Card>
<Card>
<Box p={4}>
<Stack direction="row" align="center" gap={3}>
<Box display="flex" h={12} w={12} alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/10">
<UIIcon icon={TrendingUp} size={6} color="text-primary-blue" />
</Box>
<Box>
<Text size="2xl" weight="bold" color="text-white" block>{viewData.formattedTotalRevenue}</Text>
<Text size="sm" color="text-gray-400" block>Total Revenue</Text>
</Box>
</Stack>
</Box>
</Card>
<Card>
<Box p={4}>
<Stack direction="row" align="center" gap={3}>
<Box display="flex" h={12} w={12} alignItems="center" justifyContent="center" rounded="xl" bg="bg-warning-amber/10">
<UIIcon icon={DollarSign} size={6} color="text-warning-amber" />
</Box>
<Box>
<Text size="2xl" weight="bold" color="text-white" block>{viewData.formattedTotalFees}</Text>
<Text size="sm" color="text-gray-400" block>Platform Fees (10%)</Text>
</Box>
</Stack>
</Box>
</Card>
<Card>
<Box p={4}>
<Stack direction="row" align="center" gap={3}>
<Box display="flex" h={12} w={12} alignItems="center" justifyContent="center" rounded="xl" bg="bg-purple-500/10">
<UIIcon icon={Clock} size={6} color="text-purple-400" />
</Box>
<Box>
<Text size="2xl" weight="bold" color="text-white" block>{viewData.formattedPendingPayouts}</Text>
<Text size="sm" color="text-gray-400" block>Pending Payouts</Text>
</Box>
</Stack>
</Box>
</Card>
</Grid>
{/* Transactions */}
<Card>
<Box display="flex" alignItems="center" justifyContent="between" p={4} borderBottom borderColor="border-charcoal-outline">
<Heading level={2}>Transaction History</Heading>
<Box as="select"
value={filterType}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setFilterType(e.target.value as typeof filterType)}
p={1.5}
rounded="lg"
border
borderColor="border-charcoal-outline"
bg="bg-iron-gray"
color="text-white"
fontSize="sm"
>
<Box as="option" value="all">All Transactions</Box>
<Box as="option" value="sponsorship">Sponsorships</Box>
<Box as="option" value="membership">Memberships</Box>
<Box as="option" value="withdrawal">Withdrawals</Box>
<Box as="option" value="prize">Prizes</Box>
</Box>
</Box>
{filteredTransactions.length === 0 ? (
<Box py={12} textAlign="center">
<Box display="flex" justifyContent="center" mb={4}>
<UIIcon icon={Wallet} size={12} color="text-gray-500" />
</Box>
<Heading level={3}>No Transactions</Heading>
<Box mt={2}>
<Text color="text-gray-400">
{filterType === 'all'
? 'Revenue from sponsorships and fees will appear here.'
: `No ${filterType} transactions found.`}
</Text>
</Box>
</Box>
) : (
<Box>
{filteredTransactions.map((transaction) => (
<TransactionRow
key={transaction.id}
transaction={{
id: transaction.id,
type: transaction.type,
description: transaction.description,
formattedDate: transaction.formattedDate,
formattedAmount: transaction.formattedAmount,
typeColor: transaction.type === 'withdrawal' ? 'text-red-400' : 'text-performance-green',
status: transaction.status,
statusColor: transaction.status === 'completed' ? 'text-performance-green' : 'text-warning-amber',
amountColor: transaction.type === 'withdrawal' ? 'text-red-400' : 'text-performance-green',
}}
/>
))}
</Box>
)}
</Card>
{/* Revenue Breakdown */}
<Grid cols={1} lgCols={2} gap={6} mt={6}>
<Card>
<Box p={4}>
<Heading level={3} mb={4}>Revenue Breakdown</Heading>
<Stack gap={3}>
<Box display="flex" alignItems="center" justifyContent="between">
<Stack direction="row" align="center" gap={2}>
<Box w={3} h={3} rounded="full" bg="bg-primary-blue" />
<Text color="text-gray-400">Sponsorships</Text>
</Stack>
<Text weight="medium" color="text-white">$1,600.00</Text>
</Box>
<Box display="flex" alignItems="center" justifyContent="between">
<Stack direction="row" align="center" gap={2}>
<Box w={3} h={3} rounded="full" bg="bg-performance-green" />
<Text color="text-gray-400">Membership Fees</Text>
</Stack>
<Text weight="medium" color="text-white">$1,600.00</Text>
</Box>
<Box display="flex" alignItems="center" justifyContent="between" pt={2} borderTop borderColor="border-charcoal-outline">
<Text weight="medium" color="text-gray-300">Total Gross Revenue</Text>
<Text weight="bold" color="text-white">$3,200.00</Text>
</Box>
<Box display="flex" alignItems="center" justifyContent="between">
<Text size="sm" color="text-warning-amber">Platform Fee (10%)</Text>
<Text size="sm" color="text-warning-amber">-$320.00</Text>
</Box>
<Box display="flex" alignItems="center" justifyContent="between" pt={2} borderTop borderColor="border-charcoal-outline">
<Text weight="medium" color="text-performance-green">Net Revenue</Text>
<Text weight="bold" color="text-performance-green">$2,880.00</Text>
</Box>
</Stack>
</Box>
</Card>
<Card>
<Box p={4}>
<Heading level={3} mb={4}>Payout Schedule</Heading>
<Stack gap={3}>
<Box p={3} rounded="lg" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline">
<Box display="flex" alignItems="center" justifyContent="between" mb={1}>
<Text size="sm" weight="medium" color="text-white">Season 2 Prize Pool</Text>
<Text size="sm" weight="medium" color="text-warning-amber">Pending</Text>
</Box>
<Text size="xs" color="text-gray-500">
Distributed after season completion to top 3 drivers
</Text>
</Box>
<Box p={3} rounded="lg" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline">
<Box display="flex" alignItems="center" justifyContent="between" mb={1}>
<Text size="sm" weight="medium" color="text-white">Available for Withdrawal</Text>
<Text size="sm" weight="medium" color="text-performance-green">{viewData.formattedBalance}</Text>
</Box>
<Text size="xs" color="text-gray-500">
Available after Season 2 ends (estimated: Jan 15, 2026)
</Text>
</Box>
</Stack>
</Box>
</Card>
</Grid>
{/* Withdraw Modal */}
{showWithdrawModal && onWithdraw && (
<Box position="fixed" inset="0" bg="bg-black/50" display="flex" alignItems="center" justifyContent="center" zIndex={50}>
<Card>
<Box p={6} w="full" maxWidth="28rem">
<Heading level={2} mb={4}>Withdraw Funds</Heading>
{!canWithdraw ? (
<Box p={4} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30" mb={4}>
<Text size="sm" color="text-warning-amber">{withdrawalBlockReason}</Text>
</Box>
) : (
<Stack gap={4}>
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
Amount to Withdraw
</Text>
<Box position="relative">
<Box position="absolute" left={3} top="50%" transform="translateY(-50%)">
<Text color="text-gray-500">$</Text>
</Box>
<Box as="input"
type="number"
value={withdrawAmount}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setWithdrawAmount(e.target.value)}
max={viewData.balance}
w="full"
pl={8}
pr={4}
py={2}
rounded="lg"
border
borderColor="border-charcoal-outline"
bg="bg-iron-gray"
color="text-white"
placeholder="0.00"
/>
</Box>
<Text size="xs" color="text-gray-500" block mt={1}>
Available: {viewData.formattedBalance}
</Text>
</Box>
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
Destination
</Text>
<Box as="select"
w="full"
px={3}
py={2}
rounded="lg"
border
borderColor="border-charcoal-outline"
bg="bg-iron-gray"
color="text-white"
>
<Box as="option">Bank Account ***1234</Box>
</Box>
</Box>
</Stack>
)}
<Stack direction="row" gap={3} mt={6}>
<Button
variant="secondary"
onClick={() => setShowWithdrawModal(false)}
fullWidth
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleWithdrawClick}
disabled={!canWithdraw || mutationLoading || !withdrawAmount}
fullWidth
>
{mutationLoading ? 'Processing...' : 'Withdraw'}
</Button>
</Stack>
</Box>
</Card>
</Box>
)}
{/* Alpha Notice */}
<Box mt={6} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30" p={4}>
<Text size="xs" color="text-gray-400">
<Text weight="bold" color="text-warning-amber">Alpha Note:</Text> Wallet management is demonstration-only.
Real payment processing and bank integrations will be available when the payment system is fully implemented.
The 10% platform fee and season-based withdrawal restrictions are enforced in the actual implementation.
</Text>
</Box>
</Container>
);
}

View File

@@ -1,300 +0,0 @@
'use client';
import React, { useState } from 'react';
import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import { TransactionRow } from '@/components/leagues/TransactionRow';
import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
import {
Wallet,
DollarSign,
ArrowUpRight,
Clock,
AlertTriangle,
Download,
TrendingUp
} from 'lucide-react';
interface WalletTemplateProps {
data: LeagueWalletViewModel;
onWithdraw?: (amount: number) => void;
onExport?: () => void;
mutationLoading?: boolean;
}
export function WalletTemplate({ data, onWithdraw, onExport, mutationLoading = false }: WalletTemplateProps) {
const [withdrawAmount, setWithdrawAmount] = useState('');
const [showWithdrawModal, setShowWithdrawModal] = useState(false);
const [filterType, setFilterType] = useState<'all' | 'sponsorship' | 'membership' | 'withdrawal' | 'prize'>('all');
const filteredTransactions = data.getFilteredTransactions(filterType);
const handleWithdrawClick = () => {
const amount = parseFloat(withdrawAmount);
if (!amount || amount <= 0) return;
if (onWithdraw) {
onWithdraw(amount);
setShowWithdrawModal(false);
setWithdrawAmount('');
}
};
return (
<div className="max-w-6xl mx-auto py-8 px-4">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">League Wallet</h1>
<p className="text-gray-400">Manage your league's finances and payouts</p>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={onExport}>
<Download className="w-4 h-4 mr-2" />
Export
</Button>
<Button
variant="primary"
onClick={() => setShowWithdrawModal(true)}
disabled={!data.canWithdraw || !onWithdraw}
>
<ArrowUpRight className="w-4 h-4 mr-2" />
Withdraw
</Button>
</div>
</div>
{/* Withdrawal Warning */}
{!data.canWithdraw && data.withdrawalBlockReason && (
<div className="mb-6 p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
<div>
<h3 className="font-medium text-warning-amber">Withdrawals Temporarily Unavailable</h3>
<p className="text-sm text-gray-400 mt-1">{data.withdrawalBlockReason}</p>
</div>
</div>
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-performance-green/10">
<Wallet className="w-6 h-6 text-performance-green" />
</div>
<div>
<div className="text-2xl font-bold text-white">{data.formattedBalance}</div>
<div className="text-sm text-gray-400">Available Balance</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
<TrendingUp className="w-6 h-6 text-primary-blue" />
</div>
<div>
<div className="text-2xl font-bold text-white">{data.formattedTotalRevenue}</div>
<div className="text-sm text-gray-400">Total Revenue</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-warning-amber/10">
<DollarSign className="w-6 h-6 text-warning-amber" />
</div>
<div>
<div className="text-2xl font-bold text-white">{data.formattedTotalFees}</div>
<div className="text-sm text-gray-400">Platform Fees (10%)</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-500/10">
<Clock className="w-6 h-6 text-purple-400" />
</div>
<div>
<div className="text-2xl font-bold text-white">{data.formattedPendingPayouts}</div>
<div className="text-sm text-gray-400">Pending Payouts</div>
</div>
</div>
</Card>
</div>
{/* Transactions */}
<Card>
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline">
<h2 className="text-lg font-semibold text-white">Transaction History</h2>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value as typeof filterType)}
className="px-3 py-1.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white text-sm focus:border-primary-blue focus:outline-none"
>
<option value="all">All Transactions</option>
<option value="sponsorship">Sponsorships</option>
<option value="membership">Memberships</option>
<option value="withdrawal">Withdrawals</option>
<option value="prize">Prizes</option>
</select>
</div>
{filteredTransactions.length === 0 ? (
<div className="text-center py-12">
<Wallet className="w-12 h-12 text-gray-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-white mb-2">No Transactions</h3>
<p className="text-gray-400">
{filterType === 'all'
? 'Revenue from sponsorships and fees will appear here.'
: `No ${filterType} transactions found.`}
</p>
</div>
) : (
<div>
{filteredTransactions.map((transaction) => (
<TransactionRow key={transaction.id} transaction={transaction} />
))}
</div>
)}
</Card>
{/* Revenue Breakdown */}
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Revenue Breakdown</h3>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-primary-blue" />
<span className="text-gray-400">Sponsorships</span>
</div>
<span className="font-medium text-white">$1,600.00</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-performance-green" />
<span className="text-gray-400">Membership Fees</span>
</div>
<span className="font-medium text-white">$1,600.00</span>
</div>
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<span className="text-gray-300 font-medium">Total Gross Revenue</span>
<span className="font-bold text-white">$3,200.00</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-warning-amber">Platform Fee (10%)</span>
<span className="text-warning-amber">-$320.00</span>
</div>
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<span className="text-performance-green font-medium">Net Revenue</span>
<span className="font-bold text-performance-green">$2,880.00</span>
</div>
</div>
</Card>
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Payout Schedule</h3>
<div className="space-y-3">
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-white">Season 2 Prize Pool</span>
<span className="text-sm font-medium text-warning-amber">Pending</span>
</div>
<p className="text-xs text-gray-500">
Distributed after season completion to top 3 drivers
</p>
</div>
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-white">Available for Withdrawal</span>
<span className="text-sm font-medium text-performance-green">{data.formattedBalance}</span>
</div>
<p className="text-xs text-gray-500">
Available after Season 2 ends (estimated: Jan 15, 2026)
</p>
</div>
</div>
</Card>
</div>
{/* Withdraw Modal */}
{showWithdrawModal && onWithdraw && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Card className="w-full max-w-md p-6">
<h2 className="text-xl font-semibold text-white mb-4">Withdraw Funds</h2>
{!data.canWithdraw ? (
<div className="p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30 mb-4">
<p className="text-sm text-warning-amber">{data.withdrawalBlockReason}</p>
</div>
) : (
<>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
Amount to Withdraw
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
max={data.balance}
className="w-full pl-8 pr-4 py-2 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
placeholder="0.00"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
Available: {data.formattedBalance}
</p>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
Destination
</label>
<select className="w-full px-3 py-2 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none">
<option>Bank Account ***1234</option>
</select>
</div>
</>
)}
<div className="flex gap-3 mt-6">
<Button
variant="secondary"
onClick={() => setShowWithdrawModal(false)}
className="flex-1"
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleWithdrawClick}
disabled={!data.canWithdraw || mutationLoading || !withdrawAmount}
className="flex-1"
>
{mutationLoading ? 'Processing...' : 'Withdraw'}
</Button>
</div>
</Card>
</div>
)}
{/* Alpha Notice */}
<div className="mt-6 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> Wallet management is demonstration-only.
Real payment processing and bank integrations will be available when the payment system is fully implemented.
The 10% platform fee and season-based withdrawal restrictions are enforced in the actual implementation.
</p>
</div>
</div>
);
}

View File

@@ -1,13 +1,13 @@
import { LeagueWalletPageQuery } from '@/lib/page-queries/page-queries/LeagueWalletPageQuery';
import { LeagueWalletTemplate } from '@/templates/LeagueWalletTemplate';
import { LeagueWalletPageQuery } from '@/lib/page-queries/LeagueWalletPageQuery';
import { LeagueWalletPageClient } from './LeagueWalletPageClient';
import { notFound } from 'next/navigation';
interface Props {
params: { id: string };
params: Promise<{ id: string }>;
}
export default async function LeagueWalletPage({ params }: Props) {
const leagueId = params.id;
const { id: leagueId } = await params;
if (!leagueId) {
notFound();
@@ -17,17 +17,24 @@ export default async function LeagueWalletPage({ params }: Props) {
if (result.isErr()) {
const error = result.getError();
if (error.type === 'notFound') {
if (error === 'notFound') {
notFound();
}
// For serverError, show the template with empty data
return <LeagueWalletTemplate viewData={{
return <LeagueWalletPageClient viewData={{
leagueId,
balance: 0,
formattedBalance: '$0.00',
totalRevenue: 0,
formattedTotalRevenue: '$0.00',
totalFees: 0,
formattedTotalFees: '$0.00',
pendingPayouts: 0,
formattedPendingPayouts: '$0.00',
currency: 'USD',
transactions: [],
}} />;
}
return <LeagueWalletTemplate viewData={result.unwrap()} />;
return <LeagueWalletPageClient viewData={result.unwrap()} />;
}

View File

@@ -5,6 +5,10 @@ import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Input } from '@/ui/Input';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { useAuth } from '@/lib/auth/AuthContext';
import {
AlertCircle,
@@ -22,22 +26,22 @@ import {
Users,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { FormEvent, useCallback, useEffect, useState } from 'react';
import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel';
import { useCreateLeagueWizard } from "@/lib/hooks/useLeagueWizardService";
import { useLeagueScoringPresets } from "@/lib/hooks/useLeagueScoringPresets";
import { LeagueBasicsSection } from './LeagueBasicsSection';
import { LeagueDropSection } from './LeagueDropSection';
import { useCreateLeagueWizard } from "@/hooks/useLeagueWizardService";
import { useLeagueScoringPresets } from "@/hooks/useLeagueScoringPresets";
import { LeagueBasicsSection } from '@/components/leagues/LeagueBasicsSection';
import { LeagueDropSection } from '@/components/leagues/LeagueDropSection';
import {
ChampionshipsSection,
ScoringPatternSection
} from './LeagueScoringSection';
import { LeagueStewardingSection } from './LeagueStewardingSection';
import { LeagueStructureSection } from './LeagueStructureSection';
import { LeagueTimingsSection } from './LeagueTimingsSection';
import { LeagueVisibilitySection } from './LeagueVisibilitySection';
} from '@/components/leagues/LeagueScoringSection';
import { LeagueStewardingSection } from '@/components/leagues/LeagueStewardingSection';
import { LeagueStructureSection } from '@/components/leagues/LeagueStructureSection';
import { LeagueTimingsSection } from '@/components/leagues/LeagueTimingsSection';
import { LeagueVisibilitySection } from '@/components/leagues/LeagueVisibilitySection';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
import type { Weekday } from '@/lib/types/Weekday';
@@ -237,7 +241,7 @@ function createDefaultForm(): LeagueWizardFormModel {
};
}
export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizardProps) {
export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizardProps) {
const router = useRouter();
const { session } = useAuth();
@@ -285,7 +289,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
// Sync presets from query to local state
useEffect(() => {
if (queryPresets) {
setPresets(queryPresets);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setPresets(queryPresets as any);
const firstPreset = queryPresets[0];
if (firstPreset && !form.scoring?.patternId) {
setForm((prev) => ({
@@ -316,7 +321,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
const validateStep = (currentStep: Step): boolean => {
// Convert form to LeagueWizardFormData for validation
const formData: LeagueWizardCommandModel.LeagueWizardFormData = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const formData: any = {
leagueId: form.leagueId || '',
basics: {
name: form.basics?.name || '',
@@ -365,7 +371,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
},
};
const stepErrors = LeagueWizardCommandModel.validateLeagueWizardStep(formData, currentStep);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stepErrors = LeagueWizardCommandModel.validateLeagueWizardStep(formData, currentStep as any);
setErrors((prev) => ({
...prev,
...stepErrors,
@@ -409,7 +416,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
}
// Convert form to LeagueWizardFormData for validation
const formData: LeagueWizardCommandModel.LeagueWizardFormData = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const formData: any = {
leagueId: form.leagueId || '',
basics: {
name: form.basics?.name || '',
@@ -471,7 +479,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
setLoading(true);
setErrors((prev) => {
const { submit, ...rest } = prev;
const { submit: _, ...rest } = prev;
return rest;
});
@@ -587,39 +595,45 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
const CurrentStepIcon = currentStepData?.icon ?? FileText;
return (
<form onSubmit={handleSubmit} className="max-w-4xl mx-auto pb-8">
<Box as="form" onSubmit={handleSubmit} maxWidth="4xl" mx="auto" pb={8}>
{/* Header with icon */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/20">
<Sparkles className="w-5 h-5 text-primary-blue" />
</div>
<div>
<Heading level={1} className="text-2xl sm:text-3xl">
<Box mb={8}>
<Stack direction="row" align="center" gap={3} mb={3}>
<Box display="flex" h="11" w="11" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/20" border borderColor="border-primary-blue/20">
<Icon icon={Sparkles} size={5} color="text-primary-blue" />
</Box>
<Box>
<Heading level={1} fontSize={{ base: '2xl', sm: '3xl' }}>
Create a new league
</Heading>
<p className="text-sm text-gray-500">
We'll also set up your first season in {steps.length} easy steps.
</p>
<p className="text-xs text-gray-500 mt-1">
<Text size="sm" color="text-gray-500" block>
We&apos;ll also set up your first season in {steps.length} easy steps.
</Text>
<Text size="xs" color="text-gray-500" block mt={1}>
A league is your long-term brand. Each season is a block of races you can run again and again.
</p>
</div>
</div>
</div>
</Text>
</Box>
</Stack>
</Box>
{/* Desktop Progress Bar */}
<div className="hidden md:block mb-8">
<div className="relative">
<Box display={{ base: 'none', md: 'block' }} mb={8}>
<Box position="relative">
{/* Background track */}
<div className="absolute top-5 left-6 right-6 h-0.5 bg-charcoal-outline rounded-full" />
<Box position="absolute" top="5" left="6" right="6" h="0.5" bg="bg-charcoal-outline" rounded="full" />
{/* Progress fill */}
<div
className="absolute top-5 left-6 h-0.5 bg-gradient-to-r from-primary-blue to-neon-aqua rounded-full transition-all duration-500 ease-out"
style={{ width: `calc(${((step - 1) / (steps.length - 1)) * 100}% - 48px)` }}
<Box
position="absolute"
top="5"
left="6"
h="0.5"
bg="bg-gradient-to-r from-primary-blue to-neon-aqua"
rounded="full"
transition
width={`calc(${((step - 1) / (steps.length - 1)) * 100}% - 48px)`}
/>
<div className="relative flex justify-between">
<Box position="relative" display="flex" justifyContent="between">
{steps.map((wizardStep) => {
const isCompleted = wizardStep.id < step;
const isCurrent = wizardStep.id === step;
@@ -627,148 +641,153 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
const StepIcon = wizardStep.icon;
return (
<button
<Box
as="button"
key={wizardStep.id}
type="button"
onClick={() => goToStep(wizardStep.id)}
disabled={!isAccessible}
className="flex flex-col items-center bg-transparent border-0 cursor-pointer disabled:cursor-not-allowed"
display="flex"
flexDirection="col"
alignItems="center"
bg="bg-transparent"
borderStyle="none"
cursor={isAccessible ? 'pointer' : 'not-allowed'}
opacity={!isAccessible ? 0.6 : 1}
>
<div
className={`
relative z-10 flex h-10 w-10 items-center justify-center rounded-full
transition-all duration-300 ease-out
${isCurrent
? 'bg-primary-blue text-white shadow-[0_0_24px_rgba(25,140,255,0.5)] scale-110'
: isCompleted
? 'bg-primary-blue text-white hover:scale-105'
: isAccessible
? 'bg-iron-gray text-gray-400 border-2 border-charcoal-outline hover:border-primary-blue/50'
: 'bg-iron-gray text-gray-500 border-2 border-charcoal-outline opacity-60'
}
`}
<Box
position="relative"
zIndex={10}
display="flex"
h="10"
w="10"
alignItems="center"
justifyContent="center"
rounded="full"
transition
bg={isCurrent || isCompleted ? 'bg-primary-blue' : 'bg-iron-gray'}
color={isCurrent || isCompleted ? 'text-white' : 'text-gray-400'}
border={!isCurrent && !isCompleted}
borderColor="border-charcoal-outline"
shadow={isCurrent ? '0_0_24px_rgba(25,140,255,0.5)' : undefined}
transform={isCurrent ? 'scale-110' : isCompleted ? 'hover:scale-105' : undefined}
>
{isCompleted ? (
<Check className="w-4 h-4" strokeWidth={3} />
<Icon icon={Check} size={4} strokeWidth={3} />
) : (
<StepIcon className="w-4 h-4" />
<Icon icon={StepIcon} size={4} />
)}
</div>
<div className="mt-2 text-center">
<p
className={`text-xs font-medium transition-colors duration-200 ${
isCurrent
? 'text-white'
: isCompleted
? 'text-primary-blue'
: isAccessible
? 'text-gray-400'
: 'text-gray-500'
}`}
</Box>
<Box mt={2} textAlign="center">
<Text
size="xs"
weight="medium"
transition
color={isCurrent ? 'text-white' : isCompleted ? 'text-primary-blue' : isAccessible ? 'text-gray-400' : 'text-gray-500'}
>
{wizardStep.label}
</p>
</div>
</button>
</Text>
</Box>
</Box>
);
})}
</div>
</div>
</div>
</Box>
</Box>
</Box>
{/* Mobile Progress */}
<div className="md:hidden mb-6">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<CurrentStepIcon className="w-4 h-4 text-primary-blue" />
<span className="text-sm font-medium text-white">{currentStepData?.label}</span>
</div>
<span className="text-xs text-gray-500">
<Box display={{ base: 'block', md: 'none' }} mb={6}>
<Box display="flex" alignItems="center" justifyContent="between" mb={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={CurrentStepIcon} size={4} color="text-primary-blue" />
<Text size="sm" weight="medium" color="text-white">{currentStepData?.label}</Text>
</Stack>
<Text size="xs" color="text-gray-500">
{step}/{steps.length}
</span>
</div>
<div className="h-1.5 bg-charcoal-outline rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary-blue to-neon-aqua rounded-full transition-all duration-500 ease-out"
style={{ width: `${(step / steps.length) * 100}%` }}
</Text>
</Box>
<Box h="1.5" bg="bg-charcoal-outline" rounded="full" overflow="hidden">
<Box
h="full"
bg="bg-gradient-to-r from-primary-blue to-neon-aqua"
rounded="full"
transition
height="full"
width={`${(step / steps.length) * 100}%`}
/>
</div>
</Box>
{/* Step dots */}
<div className="flex justify-between mt-2 px-0.5">
<Box display="flex" justifyContent="between" mt={2} px={0.5}>
{steps.map((s) => (
<div
<Box
key={s.id}
className={`
h-1.5 rounded-full transition-all duration-300
${s.id === step
? 'w-4 bg-primary-blue'
: s.id < step
? 'w-1.5 bg-primary-blue/60'
: 'w-1.5 bg-charcoal-outline'
}
`}
h="1.5"
rounded="full"
transition
width={s.id === step ? '4' : '1.5'}
bg={s.id === step ? 'bg-primary-blue' : s.id < step ? 'bg-primary-blue/60' : 'bg-charcoal-outline'}
/>
))}
</div>
</div>
</Box>
</Box>
{/* Main Card */}
<Card className="relative overflow-hidden">
<Card position="relative" overflow="hidden">
{/* Top gradient accent */}
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-primary-blue to-transparent" />
<Box position="absolute" top="0" left="0" right="0" h="1" bg="bg-gradient-to-r from-transparent via-primary-blue to-transparent" />
{/* Step header */}
<div className="flex items-start gap-4 mb-6">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10 shrink-0 transition-transform duration-300">
<CurrentStepIcon className="w-6 h-6 text-primary-blue" />
</div>
<div className="flex-1 min-w-0">
<Heading level={2} className="text-xl sm:text-2xl text-white leading-tight">
<div className="flex items-center gap-2 flex-wrap">
<span>{getStepTitle(step)}</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full border border-charcoal-outline bg-iron-gray/60 text-[11px] font-medium text-gray-300">
<Box display="flex" alignItems="start" gap={4} mb={6}>
<Box display="flex" h="12" w="12" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/10" flexShrink={0} transition>
<Icon icon={CurrentStepIcon} size={6} color="text-primary-blue" />
</Box>
<Box flexGrow={1} minWidth="0">
<Heading level={2} fontSize={{ base: 'xl', md: '2xl' }} color="text-white">
<Stack direction="row" align="center" gap={2} flexWrap="wrap">
<Text>{getStepTitle(step)}</Text>
<Text size="xs" weight="medium" px={2} py={0.5} rounded="full" border borderColor="border-charcoal-outline" bg="bg-iron-gray/60" color="text-gray-300">
{getStepContextLabel(step)}
</span>
</div>
</Text>
</Stack>
</Heading>
<p className="text-sm text-gray-400 mt-1">
<Text size="sm" color="text-gray-400" block mt={1}>
{getStepSubtitle(step)}
</p>
</div>
<div className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-deep-graphite border border-charcoal-outline">
<span className="text-xs text-gray-500">Step</span>
<span className="text-sm font-semibold text-white">{step}</span>
<span className="text-xs text-gray-500">/ {steps.length}</span>
</div>
</div>
</Text>
</Box>
<Box display={{ base: 'none', sm: 'flex' }} alignItems="center" gap={1.5} px={3} py={1.5} rounded="full" bg="bg-deep-graphite" border borderColor="border-charcoal-outline">
<Text size="xs" color="text-gray-500">Step</Text>
<Text size="sm" weight="semibold" color="text-white">{step}</Text>
<Text size="xs" color="text-gray-500">/ {steps.length}</Text>
</Box>
</Box>
{/* Divider */}
<div className="h-px bg-gradient-to-r from-transparent via-charcoal-outline to-transparent mb-6" />
<Box h="px" bg="bg-gradient-to-r from-transparent via-charcoal-outline to-transparent" mb={6} />
{/* Step content with min-height for consistency */}
<div className="min-h-[320px]">
<Box minHeight="320px">
{step === 1 && (
<div className="animate-fade-in space-y-8">
<Box animate="fade-in" gap={8} display="flex" flexDirection="col">
<LeagueBasicsSection
form={form}
onChange={setForm}
errors={errors.basics ?? {}}
/>
<div className="rounded-xl border border-charcoal-outline bg-iron-gray/40 p-4">
<div className="flex items-center justify-between gap-2 mb-2">
<div>
<p className="text-xs font-semibold text-gray-300 uppercase tracking-wide">
<Box rounded="xl" border borderColor="border-charcoal-outline" bg="bg-iron-gray/40" p={4}>
<Box display="flex" alignItems="center" justifyContent="between" gap={2} mb={2}>
<Box>
<Text size="xs" weight="semibold" color="text-gray-300" uppercase letterSpacing="wide">
First season
</p>
<p className="text-xs text-gray-500">
</Text>
<Text size="xs" color="text-gray-500" block>
Name the first season that will run in this league.
</p>
</div>
</div>
<div className="space-y-2 mt-2">
<label className="text-sm font-medium text-gray-300">
</Text>
</Box>
</Box>
<Box mt={2} display="flex" flexDirection="col" gap={2}>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block>
Season name
</label>
</Text>
<Input
value={form.seasonName ?? ''}
onChange={(e) =>
@@ -779,16 +798,16 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
}
placeholder="e.g., Season 1 (2025)"
/>
<p className="text-xs text-gray-500">
<Text size="xs" color="text-gray-500" block>
Seasons are the individual competitive runs inside your league. You can run Season 2, Season 3, or parallel seasons later.
</p>
</div>
</div>
</div>
</Text>
</Box>
</Box>
</Box>
)}
{step === 2 && (
<div className="animate-fade-in">
<Box animate="fade-in">
<LeagueVisibilitySection
form={form}
onChange={setForm}
@@ -798,55 +817,55 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
: {}
}
/>
</div>
</Box>
)}
{step === 3 && (
<div className="animate-fade-in space-y-4">
<div className="mb-2">
<p className="text-xs text-gray-500">
<Box animate="fade-in" display="flex" flexDirection="col" gap={4}>
<Box mb={2}>
<Text size="xs" color="text-gray-500" block>
Applies to: First season of this league.
</p>
<p className="text-xs text-gray-500">
</Text>
<Text size="xs" color="text-gray-500" block>
These settings only affect this season. Future seasons can use different formats.
</p>
</div>
</Text>
</Box>
<LeagueStructureSection
form={form}
onChange={setForm}
readOnly={false}
/>
</div>
</Box>
)}
{step === 4 && (
<div className="animate-fade-in space-y-4">
<div className="mb-2">
<p className="text-xs text-gray-500">
<Box animate="fade-in" display="flex" flexDirection="col" gap={4}>
<Box mb={2}>
<Text size="xs" color="text-gray-500" block>
Applies to: First season of this league.
</p>
<p className="text-xs text-gray-500">
</Text>
<Text size="xs" color="text-gray-500" block>
These settings only affect this season. Future seasons can use different formats.
</p>
</div>
</Text>
</Box>
<LeagueTimingsSection
form={form}
onChange={setForm}
errors={errors.timings ?? {}}
/>
</div>
</Box>
)}
{step === 5 && (
<div className="animate-fade-in space-y-8">
<div className="mb-2">
<p className="text-xs text-gray-500">
<Box animate="fade-in" display="flex" flexDirection="col" gap={8}>
<Box mb={2}>
<Text size="xs" color="text-gray-500" block>
Applies to: First season of this league.
</p>
<p className="text-xs text-gray-500">
</Text>
<Text size="xs" color="text-gray-500" block>
These settings only affect this season. Future seasons can use different formats.
</p>
</div>
</Text>
</Box>
{/* Scoring Pattern Selection */}
<ScoringPatternSection
scoring={form.scoring || {}}
@@ -866,81 +885,81 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
/>
{/* Divider */}
<div className="h-px bg-gradient-to-r from-transparent via-charcoal-outline to-transparent" />
<Box h="px" bg="bg-gradient-to-r from-transparent via-charcoal-outline to-transparent" />
{/* Championships & Drop Rules side by side on larger screens */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Box display="grid" gridCols={{ base: 1, lg: 2 }} gap={6}>
<ChampionshipsSection form={form} onChange={setForm} readOnly={presetsLoading} />
<LeagueDropSection form={form} onChange={setForm} readOnly={false} />
</div>
</Box>
{errors.submit && (
<div className="flex items-start gap-3 rounded-lg bg-warning-amber/10 p-4 border border-warning-amber/20">
<AlertCircle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
<p className="text-sm text-warning-amber">{errors.submit}</p>
</div>
<Box display="flex" alignItems="start" gap={3} rounded="lg" bg="bg-warning-amber/10" p={4} border borderColor="border-warning-amber/20">
<Icon icon={AlertCircle} size={5} color="text-warning-amber" flexShrink={0} mt={0.5} />
<Text size="sm" color="text-warning-amber">{errors.submit}</Text>
</Box>
)}
</div>
</Box>
)}
{step === 6 && (
<div className="animate-fade-in space-y-4">
<div className="mb-2">
<p className="text-xs text-gray-500">
<Box animate="fade-in" display="flex" flexDirection="col" gap={4}>
<Box mb={2}>
<Text size="xs" color="text-gray-500" block>
Applies to: First season of this league.
</p>
<p className="text-xs text-gray-500">
</Text>
<Text size="xs" color="text-gray-500" block>
These settings only affect this season. Future seasons can use different formats.
</p>
</div>
</Text>
</Box>
<LeagueStewardingSection
form={form}
onChange={setForm}
readOnly={false}
/>
</div>
</Box>
)}
{step === 7 && (
<div className="animate-fade-in space-y-6">
<Box animate="fade-in" display="flex" flexDirection="col" gap={6}>
<LeagueReviewSummary form={form} presets={presets} />
{errors.submit && (
<div className="flex items-start gap-3 rounded-lg bg-warning-amber/10 p-4 border border-warning-amber/20">
<AlertCircle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
<p className="text-sm text-warning-amber">{errors.submit}</p>
</div>
<Box display="flex" alignItems="start" gap={3} rounded="lg" bg="bg-warning-amber/10" p={4} border borderColor="border-warning-amber/20">
<Icon icon={AlertCircle} size={5} color="text-warning-amber" flexShrink={0} mt={0.5} />
<Text size="sm" color="text-warning-amber">{errors.submit}</Text>
</Box>
)}
</div>
</Box>
)}
</div>
</Box>
</Card>
{/* Navigation */}
<div className="flex justify-between items-center mt-6">
<Box display="flex" alignItems="center" justifyContent="between" mt={6}>
<Button
type="button"
variant="secondary"
disabled={step === 1 || loading}
onClick={goToPreviousStep}
className="flex items-center gap-2"
icon={<Icon icon={ChevronLeft} size={4} />}
>
<ChevronLeft className="w-4 h-4" />
<span className="hidden sm:inline">Back</span>
<Text display={{ base: 'none', md: 'inline-block' }}>Back</Text>
</Button>
<div className="flex items-center gap-3">
<Box display="flex" alignItems="center" gap={3}>
{/* Mobile step dots */}
<div className="flex sm:hidden items-center gap-1">
<Box display={{ base: 'flex', sm: 'none' }} alignItems="center" gap={1}>
{steps.map((s) => (
<div
<Box
key={s.id}
className={`
h-1.5 rounded-full transition-all duration-300
${s.id === step ? 'w-3 bg-primary-blue' : s.id < step ? 'w-1.5 bg-primary-blue/50' : 'w-1.5 bg-charcoal-outline'}
`}
h="1.5"
rounded="full"
transition
width={s.id === step ? '3' : '1.5'}
bg={s.id === step ? 'bg-primary-blue' : s.id < step ? 'bg-primary-blue/50' : 'bg-charcoal-outline'}
/>
))}
</div>
</Box>
{step < 7 ? (
<Button
@@ -948,38 +967,34 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
variant="primary"
disabled={loading}
onClick={goToNextStep}
className="flex items-center gap-2"
icon={<Icon icon={ChevronRight} size={4} />}
flexDirection="row-reverse"
>
<span>Continue</span>
<ChevronRight className="w-4 h-4" />
<Text>Continue</Text>
</Button>
) : (
<Button
type="submit"
variant="primary"
disabled={loading}
className="flex items-center gap-2 min-w-[150px] justify-center"
minWidth="150px"
justifyContent="center"
icon={loading ? <Icon icon={Loader2} size={4} animate="spin" /> : <Icon icon={Sparkles} size={4} />}
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Creating…</span>
</>
<Text>Creating</Text>
) : (
<>
<Sparkles className="w-4 h-4" />
<span>Create League</span>
</>
<Text>Create League</Text>
)}
</Button>
)}
</div>
</div>
</Box>
</Box>
{/* Helper text */}
<p className="text-center text-xs text-gray-500 mt-4">
<Text size="xs" color="text-gray-500" align="center" block mt={4}>
This will create your league and its first season. You can edit both later.
</p>
</form>
</Text>
</Box>
);
}
}

View File

@@ -2,47 +2,42 @@
import React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import CreateLeagueWizard from '@/components/leagues/CreateLeagueWizard';
import { CreateLeagueWizard } from './CreateLeagueWizard';
import { Section } from '@/ui/Section';
import { Container } from '@/ui/Container';
import { SearchParamParser } from '@/lib/routing/search-params/SearchParamParser';
import { SearchParamBuilder } from '@/lib/routing/search-params/SearchParamBuilder';
type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'stewarding' | 'review';
function normalizeStepName(raw: string | null): StepName {
switch (raw) {
case 'basics':
case 'visibility':
case 'structure':
case 'schedule':
case 'scoring':
case 'stewarding':
case 'review':
return raw;
default:
return 'basics';
}
}
export default function CreateLeaguePage() {
const router = useRouter();
const searchParams = useSearchParams();
const currentStepName = normalizeStepName(
searchParams && typeof searchParams.get === 'function'
? searchParams.get('step')
: null,
);
const wizardParams = SearchParamParser.parseWizard(searchParams as any).unwrap();
const rawStep = wizardParams.step;
let currentStepName: StepName = 'basics';
if (rawStep === 'basics' ||
rawStep === 'visibility' ||
rawStep === 'structure' ||
rawStep === 'schedule' ||
rawStep === 'scoring' ||
rawStep === 'stewarding' ||
rawStep === 'review') {
currentStepName = rawStep;
}
const handleStepChange = (stepName: StepName) => {
const params = new URLSearchParams(
searchParams && typeof searchParams.toString === 'function'
? searchParams.toString()
: '',
);
params.set('step', stepName);
const query = params.toString();
const href = query ? `/leagues/create?${query}` : '/leagues/create';
router.push(href);
const builder = new SearchParamBuilder();
// Copy existing params if needed, but here we just want to set the step
if (searchParams) {
searchParams.forEach((value, key) => {
if (key !== 'step') builder.set(key, value);
});
}
builder.step(stepName);
router.push(`/leagues/create${builder.build()}`);
};
return (

View File

@@ -1,6 +1,6 @@
import { notFound } from 'next/navigation';
import { LeaguesClient } from '@/components/leagues/LeaguesClient';
import { LeaguesPageQuery } from '@/lib/page-queries/page-queries/LeaguesPageQuery';
import { LeaguesPageClient } from './LeaguesPageClient';
import { LeaguesPageQuery } from '@/lib/page-queries/LeaguesPageQuery';
export default async function Page() {
// Execute the PageQuery
@@ -20,11 +20,11 @@ export default async function Page() {
case 'UNKNOWN_ERROR':
default:
// Return error state - use LeaguesTemplate with empty data
return <LeaguesClient viewData={{ leagues: [] }} />;
return <LeaguesPageClient viewData={{ leagues: [] }} />;
}
}
const viewData = result.unwrap();
return <LeaguesClient viewData={viewData} />;
return <LeaguesPageClient viewData={viewData} />;
}