website refactor
This commit is contained in:
@@ -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 "<Text color="text-primary-blue">{searchQuery}</Text>"
|
||||
</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>
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
/>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
374
apps/website/app/leagues/[id]/wallet/LeagueWalletPageClient.tsx
Normal file
374
apps/website/app/leagues/[id]/wallet/LeagueWalletPageClient.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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()} />;
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user