website refactor
This commit is contained in:
@@ -1,42 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
|
||||
import React, { useState } from 'react';
|
||||
import { LeagueCard } from '@/components/leagues/LeagueCard';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { LeagueSummaryViewModelBuilder } from '@/lib/builders/view-models/LeagueSummaryViewModelBuilder';
|
||||
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
|
||||
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 { Heading } from '@/ui/Heading';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Input } from '@/ui/Input';
|
||||
import {
|
||||
Award,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Filter,
|
||||
Flag,
|
||||
Flame,
|
||||
Globe,
|
||||
Plus,
|
||||
Search,
|
||||
Sparkles,
|
||||
Target,
|
||||
Timer,
|
||||
Trophy,
|
||||
Users,
|
||||
Flag,
|
||||
Award,
|
||||
Timer,
|
||||
Clock,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
@@ -50,12 +40,9 @@ type CategoryId =
|
||||
| 'trophy'
|
||||
| 'new'
|
||||
| 'popular'
|
||||
| 'iracing'
|
||||
| 'acc'
|
||||
| 'f1'
|
||||
| 'openSlots'
|
||||
| 'endurance'
|
||||
| 'sprint'
|
||||
| 'openSlots';
|
||||
| 'sprint';
|
||||
|
||||
interface Category {
|
||||
id: CategoryId;
|
||||
@@ -66,17 +53,6 @@ interface Category {
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface LeagueSliderProps {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
description: string;
|
||||
leagues: LeaguesViewData['leagues'];
|
||||
autoScroll?: boolean;
|
||||
iconColor?: string;
|
||||
scrollSpeedMultiplier?: number;
|
||||
scrollDirection?: 'left' | 'right';
|
||||
}
|
||||
|
||||
interface LeaguesTemplateProps {
|
||||
viewData: LeaguesViewData;
|
||||
}
|
||||
@@ -114,7 +90,7 @@ const CATEGORIES: Category[] = [
|
||||
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
|
||||
return new Date(league.createdAt) > oneWeekAgo;
|
||||
},
|
||||
color: 'text-performance-green',
|
||||
color: 'text-green-500',
|
||||
},
|
||||
{
|
||||
id: 'openSlots',
|
||||
@@ -122,17 +98,15 @@ const CATEGORIES: Category[] = [
|
||||
icon: Target,
|
||||
description: 'Leagues with available spots',
|
||||
filter: (league) => {
|
||||
// Check for team slots if it's a team league
|
||||
if (league.maxTeams && league.maxTeams > 0) {
|
||||
const usedTeams = league.usedTeamSlots ?? 0;
|
||||
return usedTeams < league.maxTeams;
|
||||
}
|
||||
// Otherwise check driver slots
|
||||
const used = league.usedDriverSlots ?? 0;
|
||||
const max = league.maxDrivers ?? 0;
|
||||
return max > 0 && used < max;
|
||||
},
|
||||
color: 'text-neon-aqua',
|
||||
color: 'text-cyan-400',
|
||||
},
|
||||
{
|
||||
id: 'driver',
|
||||
@@ -183,459 +157,132 @@ const CATEGORIES: Category[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// LEAGUE SLIDER COMPONENT
|
||||
// ============================================================================
|
||||
export function LeaguesPageClient({ viewData }: LeaguesTemplateProps) {
|
||||
const router = useRouter();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryId>('all');
|
||||
|
||||
function LeagueSlider({
|
||||
title,
|
||||
icon: Icon,
|
||||
description,
|
||||
leagues,
|
||||
autoScroll = true,
|
||||
iconColor = 'text-primary-blue',
|
||||
scrollSpeedMultiplier = 1,
|
||||
scrollDirection = 'right',
|
||||
}: LeagueSliderProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(true);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const animationRef = useRef<number | null>(null);
|
||||
const scrollPositionRef = useRef(0);
|
||||
const filteredLeagues = viewData.leagues.filter((league) => {
|
||||
const matchesSearch = !searchQuery ||
|
||||
league.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(league.description ?? '').toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const category = CATEGORIES.find(c => c.id === activeCategory);
|
||||
const matchesCategory = !category || category.filter(league);
|
||||
|
||||
const checkScrollButtons = useCallback(() => {
|
||||
if (scrollRef.current) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||
setCanScrollLeft(scrollLeft > 0);
|
||||
setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 10);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scroll = useCallback((direction: 'left' | 'right') => {
|
||||
if (scrollRef.current) {
|
||||
const cardWidth = 340;
|
||||
const scrollAmount = direction === 'left' ? -cardWidth : cardWidth;
|
||||
// Update the ref so auto-scroll continues from new position
|
||||
scrollPositionRef.current = scrollRef.current.scrollLeft + scrollAmount;
|
||||
scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize scroll position for left-scrolling sliders
|
||||
const initializeScroll = useCallback(() => {
|
||||
if (scrollDirection === 'left' && scrollRef.current) {
|
||||
const { scrollWidth, clientWidth } = scrollRef.current;
|
||||
scrollPositionRef.current = scrollWidth - clientWidth;
|
||||
scrollRef.current.scrollLeft = scrollPositionRef.current;
|
||||
}
|
||||
}, [scrollDirection]);
|
||||
|
||||
// Smooth continuous auto-scroll using requestAnimationFrame with variable speed and direction
|
||||
const setupAutoScroll = useCallback(() => {
|
||||
// Allow scroll even with just 2 leagues (minimum threshold = 1)
|
||||
if (!autoScroll || leagues.length <= 1) return;
|
||||
|
||||
const scrollContainer = scrollRef.current;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
let lastTimestamp = 0;
|
||||
// Base speed with multiplier for variation between sliders
|
||||
const baseSpeed = 0.025;
|
||||
const scrollSpeed = baseSpeed * scrollSpeedMultiplier;
|
||||
const directionMultiplier = scrollDirection === 'left' ? -1 : 1;
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (!isHovering && scrollContainer) {
|
||||
const delta = lastTimestamp ? timestamp - lastTimestamp : 0;
|
||||
lastTimestamp = timestamp;
|
||||
|
||||
scrollPositionRef.current += scrollSpeed * delta * directionMultiplier;
|
||||
|
||||
const { scrollWidth, clientWidth } = scrollContainer;
|
||||
const maxScroll = scrollWidth - clientWidth;
|
||||
|
||||
// Handle wrap-around for both directions
|
||||
if (scrollDirection === 'right' && scrollPositionRef.current >= maxScroll) {
|
||||
scrollPositionRef.current = 0;
|
||||
} else if (scrollDirection === 'left' && scrollPositionRef.current <= 0) {
|
||||
scrollPositionRef.current = maxScroll;
|
||||
}
|
||||
|
||||
scrollContainer.scrollLeft = scrollPositionRef.current;
|
||||
} else {
|
||||
lastTimestamp = timestamp;
|
||||
}
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [autoScroll, leagues.length, isHovering, scrollSpeedMultiplier, scrollDirection]);
|
||||
|
||||
// Sync scroll position when user manually scrolls
|
||||
const setupManualScroll = useCallback(() => {
|
||||
const scrollContainer = scrollRef.current;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
scrollPositionRef.current = scrollContainer.scrollLeft;
|
||||
checkScrollButtons();
|
||||
};
|
||||
|
||||
scrollContainer.addEventListener('scroll', handleScroll);
|
||||
return () => scrollContainer.removeEventListener('scroll', handleScroll);
|
||||
}, [checkScrollButtons]);
|
||||
|
||||
// Initialize effects
|
||||
useState(() => {
|
||||
initializeScroll();
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
// Setup auto-scroll effect
|
||||
useState(() => {
|
||||
setupAutoScroll();
|
||||
});
|
||||
|
||||
// Setup manual scroll effect
|
||||
useState(() => {
|
||||
setupManualScroll();
|
||||
});
|
||||
|
||||
if (leagues.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box mb={10}>
|
||||
{/* Section header */}
|
||||
<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}
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box minHeight="screen" bg="zinc-950" color="text-zinc-200">
|
||||
<Box maxWidth="7xl" mx="auto" px={{ base: 4, sm: 6, lg: 8 }} py={12}>
|
||||
{/* Hero */}
|
||||
<Box as="header" display="flex" flexDirection={{ base: 'col', md: 'row' }} alignItems={{ base: 'start', md: 'end' }} justifyContent="between" gap={8} mb={16}>
|
||||
<Stack gap={4}>
|
||||
<Box display="flex" alignItems="center" gap={3} color="text-blue-500">
|
||||
<Trophy size={24} />
|
||||
<Text fontSize="xs" weight="bold" uppercase letterSpacing="widest">Competition Hub</Text>
|
||||
</Box>
|
||||
<Heading level={1} fontSize="5xl" weight="bold" color="text-white">
|
||||
Find Your <Text as="span" color="text-blue-500">Grid</Text>
|
||||
</Heading>
|
||||
<Text color="text-zinc-400" maxWidth="md" leading="relaxed">
|
||||
From casual sprints to epic endurance battles — discover the perfect league for your racing style.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Navigation arrows */}
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => scroll('left')}
|
||||
disabled={!canScrollLeft}
|
||||
size="sm"
|
||||
w="2rem"
|
||||
h="2rem"
|
||||
p={0}
|
||||
>
|
||||
<UIIcon icon={ChevronLeft} size={4} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => scroll('right')}
|
||||
disabled={!canScrollRight}
|
||||
size="sm"
|
||||
w="2rem"
|
||||
h="2rem"
|
||||
p={0}
|
||||
>
|
||||
<UIIcon icon={ChevronRight} size={4} />
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
<Box display="flex" flexDirection="col" alignItems="end">
|
||||
<Text fontSize="2xl" weight="bold" color="text-white" font="mono">{viewData.leagues.length}</Text>
|
||||
<Text weight="bold" color="text-zinc-500" uppercase letterSpacing="widest" fontSize="10px">Active Leagues</Text>
|
||||
</Box>
|
||||
<Box w="px" h="8" bg="zinc-800" />
|
||||
<Button
|
||||
onClick={() => router.push(routes.league.create)}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Plus size={16} />
|
||||
Create League
|
||||
</Stack>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Scrollable container with fade edges */}
|
||||
<Box position="relative">
|
||||
{/* Left fade gradient */}
|
||||
<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 */}
|
||||
<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" />
|
||||
|
||||
<Box
|
||||
ref={scrollRef}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
display="flex"
|
||||
gap={4}
|
||||
overflow="auto"
|
||||
pb={4}
|
||||
px={4}
|
||||
hideScrollbar
|
||||
>
|
||||
{leagues.map((league) => {
|
||||
const viewModel = LeagueSummaryViewModelBuilder.build(league);
|
||||
|
||||
return (
|
||||
<Box key={league.id} flexShrink={0} w="320px" h="full">
|
||||
<UILink href={routes.league.detail(league.id)} block h="full">
|
||||
<LeagueCard league={viewModel} />
|
||||
</UILink>
|
||||
{/* Search & Filters */}
|
||||
<Box as="section" display="flex" flexDirection="col" gap={8} mb={12}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search leagues by name, description, or game..."
|
||||
value={searchQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
|
||||
icon={<Search size={20} />}
|
||||
/>
|
||||
|
||||
<Box as="nav" display="flex" flexWrap="wrap" gap={2}>
|
||||
{CATEGORIES.map((category) => {
|
||||
const isActive = activeCategory === category.id;
|
||||
const CategoryIcon = category.icon;
|
||||
return (
|
||||
<Button
|
||||
key={category.id}
|
||||
onClick={() => setActiveCategory(category.id)}
|
||||
variant={isActive ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Box
|
||||
color={!isActive && category.color ? category.color : undefined}
|
||||
>
|
||||
<CategoryIcon size={14} />
|
||||
</Box>
|
||||
<Text>{category.label}</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Grid */}
|
||||
<Box as="main">
|
||||
{filteredLeagues.length > 0 ? (
|
||||
<Box display="grid" responsiveGridCols={{ base: 1, md: 2, lg: 3 }} gap={6}>
|
||||
{filteredLeagues.map((league) => (
|
||||
<LeagueCard
|
||||
key={league.id}
|
||||
id={league.id}
|
||||
name={league.name}
|
||||
description={league.description || undefined}
|
||||
coverUrl={getMediaUrl('league-cover', league.id)}
|
||||
logoUrl={league.logoUrl || undefined}
|
||||
gameName={league.scoring?.gameName}
|
||||
memberCount={league.usedDriverSlots || 0}
|
||||
maxMembers={league.maxDrivers}
|
||||
championshipType={(league.scoring?.primaryChampionshipType as 'driver' | 'team' | 'nations' | 'trophy') || 'driver'}
|
||||
onClick={() => router.push(routes.league.detail(league.id))}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Box display="flex" flexDirection="col" alignItems="center" justifyContent="center" py={24} border borderStyle="dashed" borderColor="zinc-800" bg="zinc-900/20">
|
||||
<Box color="text-zinc-800" mb={4}>
|
||||
<Search size={48} />
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Heading level={3} fontSize="xl" weight="bold" color="text-zinc-500">No Leagues Found</Heading>
|
||||
<Text color="text-zinc-600" size="sm" mt={2}>Try adjusting your search or filters</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
mt={6}
|
||||
onClick={() => { setSearchQuery(''); setActiveCategory('all'); }}
|
||||
>
|
||||
Clear All Filters
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN TEMPLATE COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function LeaguesPageClient({
|
||||
viewData,
|
||||
}: LeaguesTemplateProps) {
|
||||
const router = useRouter();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryId>('all');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Filter by search query
|
||||
const searchFilteredLeagues = viewData.leagues.filter((league) => {
|
||||
if (!searchQuery) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
league.name.toLowerCase().includes(query) ||
|
||||
(league.description ?? '').toLowerCase().includes(query) ||
|
||||
(league.scoring?.gameName ?? '').toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
// Get leagues for active category
|
||||
const activeCategoryData = CATEGORIES.find((c) => c.id === activeCategory);
|
||||
const categoryFilteredLeagues = activeCategoryData
|
||||
? searchFilteredLeagues.filter(activeCategoryData.filter)
|
||||
: searchFilteredLeagues;
|
||||
|
||||
// Group leagues by category for slider view
|
||||
const leaguesByCategory = CATEGORIES.reduce(
|
||||
(acc, category) => {
|
||||
// First try to use the dedicated category field, fall back to scoring-based filtering
|
||||
acc[category.id] = searchFilteredLeagues.filter((league) => {
|
||||
// If league has a category field, use it directly
|
||||
if (league.category) {
|
||||
return league.category === category.id;
|
||||
}
|
||||
// Otherwise fall back to the existing scoring-based filter
|
||||
return category.filter(league);
|
||||
});
|
||||
return acc;
|
||||
},
|
||||
{} as Record<CategoryId, LeaguesViewData['leagues']>,
|
||||
);
|
||||
|
||||
// Featured categories to show as sliders with different scroll speeds and alternating directions
|
||||
const featuredCategoriesWithSpeed: { id: CategoryId; speed: number; direction: 'left' | 'right' }[] = [
|
||||
{ id: 'popular', speed: 1.0, direction: 'right' },
|
||||
{ id: 'new', speed: 1.3, direction: 'left' },
|
||||
{ id: 'driver', speed: 0.8, direction: 'right' },
|
||||
{ id: 'team', speed: 1.1, direction: 'left' },
|
||||
{ id: 'nations', speed: 0.9, direction: 'right' },
|
||||
{ id: 'endurance', speed: 0.7, direction: 'left' },
|
||||
{ id: 'sprint', speed: 1.2, direction: 'right' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Container size="lg" pb={12}>
|
||||
{/* Hero Section */}
|
||||
<PageHero
|
||||
title="Find Your Grid"
|
||||
description="From casual sprints to epic endurance battles — discover the perfect league for your racing style."
|
||||
icon={Trophy}
|
||||
stats={[
|
||||
{ value: viewData.leagues.length, label: 'active leagues', color: 'bg-performance-green', animate: true },
|
||||
{ value: leaguesByCategory.new.length, label: 'new this week', color: 'bg-primary-blue' },
|
||||
{ value: leaguesByCategory.openSlots.length, label: 'with open slots', color: 'bg-neon-aqua' },
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
label: 'Create League',
|
||||
onClick: () => { router.push(routes.league.create); },
|
||||
icon: Plus,
|
||||
description: 'Set up your own racing series'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Search and Filter Bar */}
|
||||
<Box mb={6}>
|
||||
<Stack direction="row" gap={4} wrap>
|
||||
{/* Search */}
|
||||
<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)}
|
||||
icon={<UIIcon icon={Search} size={5} color="text-gray-500" />}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Filter toggle (mobile) */}
|
||||
<Box display={{ base: 'block', lg: 'none' }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<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' : { base: 'none', lg: 'block' }}>
|
||||
<Stack direction="row" gap={2} wrap>
|
||||
{CATEGORIES.map((category) => {
|
||||
const count = leaguesByCategory[category.id].length;
|
||||
const isActive = activeCategory === category.id;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={category.id}
|
||||
type="button"
|
||||
variant={isActive ? 'primary' : 'secondary'}
|
||||
onClick={() => setActiveCategory(category.id)}
|
||||
size="sm"
|
||||
rounded="full"
|
||||
>
|
||||
<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>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
{viewData.leagues.length === 0 ? (
|
||||
/* Empty State */
|
||||
<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>
|
||||
</Box>
|
||||
</Card>
|
||||
) : activeCategory === 'all' && !searchQuery ? (
|
||||
/* Slider View - Show featured categories with sliders at different speeds and directions */
|
||||
<Box>
|
||||
{featuredCategoriesWithSpeed
|
||||
.map(({ id, speed, direction }) => {
|
||||
const category = CATEGORIES.find((c) => c.id === id)!;
|
||||
return { category, speed, direction };
|
||||
})
|
||||
.filter(({ category }) => leaguesByCategory[category.id].length > 0)
|
||||
.map(({ category, speed, direction }) => (
|
||||
<LeagueSlider
|
||||
key={category.id}
|
||||
title={category.label}
|
||||
icon={category.icon}
|
||||
description={category.description}
|
||||
leagues={leaguesByCategory[category.id]}
|
||||
autoScroll={true}
|
||||
iconColor={category.color || 'text-primary-blue'}
|
||||
scrollSpeedMultiplier={speed}
|
||||
scrollDirection={direction}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
/* Grid View - Filtered by category or search */
|
||||
<Box>
|
||||
{categoryFilteredLeagues.length > 0 ? (
|
||||
<>
|
||||
<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 && (
|
||||
<Box as="span">
|
||||
{' '}
|
||||
for "<Text color="text-primary-blue">{searchQuery}</Text>"
|
||||
</Box>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Grid cols={1} mdCols={2} lgCols={3} gap={6}>
|
||||
{categoryFilteredLeagues.map((league) => {
|
||||
const viewModel = LeagueSummaryViewModelBuilder.build(league);
|
||||
|
||||
return (
|
||||
<GridItem key={league.id}>
|
||||
<UILink href={routes.league.detail(league.id)} block h="full">
|
||||
<LeagueCard league={viewModel} />
|
||||
</UILink>
|
||||
</GridItem>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
|
||||
import { LeagueOverviewTemplate } from '@/templates/LeagueOverviewTemplate';
|
||||
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
|
||||
import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder';
|
||||
import { ErrorBanner } from '@/ui/ErrorBanner';
|
||||
@@ -49,8 +49,6 @@ export default async function Page({ params }: Props) {
|
||||
});
|
||||
|
||||
return (
|
||||
<LeagueDetailTemplate viewData={viewData} tabs={[]}>
|
||||
{null}
|
||||
</LeagueDetailTemplate>
|
||||
<LeagueOverviewTemplate viewData={viewData} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,13 +16,14 @@ import {
|
||||
createRaceAction,
|
||||
updateRaceAction,
|
||||
deleteRaceAction
|
||||
} from './actions';
|
||||
} from '@/app/actions/leagueScheduleActions';
|
||||
import { RaceScheduleCommandModel } from '@/lib/command-models/leagues/RaceScheduleCommandModel';
|
||||
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';
|
||||
import { ConfirmDialog } from '@/components/shared/ux/ConfirmDialog';
|
||||
|
||||
export function LeagueAdminSchedulePageClient() {
|
||||
const params = useParams();
|
||||
@@ -39,6 +40,8 @@ export function LeagueAdminSchedulePageClient() {
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [deletingRaceId, setDeletingRaceId] = useState<string | null>(null);
|
||||
const [raceToDelete, setRaceToDelete] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Check admin status using domain hook
|
||||
const { data: isAdmin, isLoading: isAdminLoading } = useLeagueAdminStatus(leagueId, currentDriverId);
|
||||
@@ -48,7 +51,7 @@ export function LeagueAdminSchedulePageClient() {
|
||||
|
||||
// Auto-select season
|
||||
const selectedSeasonId = seasonId || (seasonsData && seasonsData.length > 0
|
||||
? (seasonsData.find((s) => s.status === 'active') ?? seasonsData[0])?.seasonId
|
||||
? (seasonsData.find((s) => s.status === 'active') ?? seasonsData[0])?.seasonId || ''
|
||||
: '');
|
||||
|
||||
// Load schedule using domain hook
|
||||
@@ -65,6 +68,7 @@ export function LeagueAdminSchedulePageClient() {
|
||||
if (!schedule || !selectedSeasonId) return;
|
||||
|
||||
setIsPublishing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = schedule.published
|
||||
? await unpublishScheduleAction(leagueId, selectedSeasonId)
|
||||
@@ -73,7 +77,7 @@ export function LeagueAdminSchedulePageClient() {
|
||||
if (result.isOk()) {
|
||||
router.refresh();
|
||||
} else {
|
||||
alert(result.getError());
|
||||
setError(result.getError());
|
||||
}
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
@@ -89,6 +93,7 @@ export function LeagueAdminSchedulePageClient() {
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = !editingRaceId
|
||||
? await createRaceAction(leagueId, selectedSeasonId, form.toCommand())
|
||||
@@ -100,7 +105,7 @@ export function LeagueAdminSchedulePageClient() {
|
||||
setEditingRaceId(null);
|
||||
router.refresh();
|
||||
} else {
|
||||
alert(result.getError());
|
||||
setError(result.getError());
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
@@ -120,18 +125,22 @@ export function LeagueAdminSchedulePageClient() {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDelete = async (raceId: string) => {
|
||||
if (!selectedSeasonId) return;
|
||||
const confirmed = window.confirm('Delete this race?');
|
||||
if (!confirmed) return;
|
||||
const handleDelete = (raceId: string) => {
|
||||
setRaceToDelete(raceId);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!selectedSeasonId || !raceToDelete) return;
|
||||
|
||||
setDeletingRaceId(raceId);
|
||||
setDeletingRaceId(raceToDelete);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await deleteRaceAction(leagueId, selectedSeasonId, raceId);
|
||||
const result = await deleteRaceAction(leagueId, selectedSeasonId, raceToDelete);
|
||||
if (result.isOk()) {
|
||||
router.refresh();
|
||||
setRaceToDelete(null);
|
||||
} else {
|
||||
alert(result.getError());
|
||||
setError(result.getError());
|
||||
}
|
||||
} finally {
|
||||
setDeletingRaceId(null);
|
||||
@@ -186,34 +195,47 @@ export function LeagueAdminSchedulePageClient() {
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<LeagueAdminScheduleTemplate
|
||||
viewData={data}
|
||||
onSeasonChange={handleSeasonChange}
|
||||
onPublishToggle={handlePublishToggle}
|
||||
onAddOrSave={handleAddOrSave}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
track={form.track}
|
||||
car={form.car}
|
||||
scheduledAtIso={form.scheduledAtIso}
|
||||
editingRaceId={editingRaceId}
|
||||
isPublishing={isPublishing}
|
||||
isSaving={isSaving}
|
||||
isDeleting={deletingRaceId}
|
||||
setTrack={(val) => {
|
||||
form.track = val;
|
||||
setForm(new RaceScheduleCommandModel(form.toCommand()));
|
||||
}}
|
||||
setCar={(val) => {
|
||||
form.car = val;
|
||||
setForm(new RaceScheduleCommandModel(form.toCommand()));
|
||||
}}
|
||||
setScheduledAtIso={(val) => {
|
||||
form.scheduledAtIso = val;
|
||||
setForm(new RaceScheduleCommandModel(form.toCommand()));
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<LeagueAdminScheduleTemplate
|
||||
viewData={data}
|
||||
onSeasonChange={handleSeasonChange}
|
||||
onPublishToggle={handlePublishToggle}
|
||||
onAddOrSave={handleAddOrSave}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
track={form.track}
|
||||
car={form.car}
|
||||
scheduledAtIso={form.scheduledAtIso}
|
||||
editingRaceId={editingRaceId}
|
||||
isPublishing={isPublishing}
|
||||
isSaving={isSaving}
|
||||
isDeleting={deletingRaceId}
|
||||
error={error}
|
||||
setTrack={(val) => {
|
||||
form.track = val;
|
||||
setForm(new RaceScheduleCommandModel(form.toCommand()));
|
||||
}}
|
||||
setCar={(val) => {
|
||||
form.car = val;
|
||||
setForm(new RaceScheduleCommandModel(form.toCommand()));
|
||||
}}
|
||||
setScheduledAtIso={(val) => {
|
||||
form.scheduledAtIso = val;
|
||||
setForm(new RaceScheduleCommandModel(form.toCommand()));
|
||||
}}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
isOpen={!!raceToDelete}
|
||||
onClose={() => setRaceToDelete(null)}
|
||||
onConfirm={confirmDelete}
|
||||
title="Delete Race"
|
||||
description="Are you sure you want to delete this race? This will remove it from the schedule and cannot be undone."
|
||||
confirmLabel="Delete Race"
|
||||
variant="danger"
|
||||
isLoading={!!deletingRaceId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { ScheduleAdminMutation } from '@/lib/mutations/leagues/ScheduleAdminMutation';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
export async function publishScheduleAction(leagueId: string, seasonId: string): Promise<Result<void, string>> {
|
||||
const mutation = new ScheduleAdminMutation();
|
||||
const result = await mutation.publishSchedule(leagueId, seasonId);
|
||||
|
||||
if (result.isOk()) {
|
||||
revalidatePath(routes.league.schedule(leagueId));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function unpublishScheduleAction(leagueId: string, seasonId: string): Promise<Result<void, string>> {
|
||||
const mutation = new ScheduleAdminMutation();
|
||||
const result = await mutation.unpublishSchedule(leagueId, seasonId);
|
||||
|
||||
if (result.isOk()) {
|
||||
revalidatePath(routes.league.schedule(leagueId));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function createRaceAction(
|
||||
leagueId: string,
|
||||
seasonId: string,
|
||||
input: { track: string; car: string; scheduledAtIso: string }
|
||||
): Promise<Result<void, string>> {
|
||||
const mutation = new ScheduleAdminMutation();
|
||||
const result = await mutation.createRace(leagueId, seasonId, input);
|
||||
|
||||
if (result.isOk()) {
|
||||
revalidatePath(routes.league.schedule(leagueId));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateRaceAction(
|
||||
leagueId: string,
|
||||
seasonId: string,
|
||||
raceId: string,
|
||||
input: Partial<{ track: string; car: string; scheduledAtIso: string }>
|
||||
): Promise<Result<void, string>> {
|
||||
const mutation = new ScheduleAdminMutation();
|
||||
const result = await mutation.updateRace(leagueId, seasonId, raceId, input);
|
||||
|
||||
if (result.isOk()) {
|
||||
revalidatePath(routes.league.schedule(leagueId));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteRaceAction(leagueId: string, seasonId: string, raceId: string): Promise<Result<void, string>> {
|
||||
const mutation = new ScheduleAdminMutation();
|
||||
const result = await mutation.deleteRace(leagueId, seasonId, raceId);
|
||||
|
||||
if (result.isOk()) {
|
||||
revalidatePath(routes.league.schedule(leagueId));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { StewardingQueuePanel } from '@/components/leagues/StewardingQueuePanel';
|
||||
import { PenaltyFAB } from '@/ui/PenaltyFAB';
|
||||
import { QuickPenaltyModal } from '@/components/leagues/QuickPenaltyModal';
|
||||
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
|
||||
@@ -8,12 +9,10 @@ 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';
|
||||
@@ -26,7 +25,7 @@ interface StewardingTemplateProps {
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
export function StewardingPageClient({ data, leagueId, currentDriverId, onRefetch }: StewardingTemplateProps) {
|
||||
export function StewardingPageClient({ data, currentDriverId, onRefetch }: StewardingTemplateProps) {
|
||||
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
|
||||
const [selectedProtest, setSelectedProtest] = useState<ProtestViewModel | null>(null);
|
||||
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
|
||||
@@ -36,19 +35,16 @@ export function StewardingPageClient({ data, leagueId, currentDriverId, onRefetc
|
||||
|
||||
// Flatten protests for the specialized list components
|
||||
const allPendingProtests = useMemo(() => {
|
||||
return data.races.flatMap(r => r.pendingProtests.map(p => new ProtestViewModel({
|
||||
return data.races.flatMap(r => r.pendingProtests.map(p => ({
|
||||
id: p.id,
|
||||
protestingDriverId: p.protestingDriverId,
|
||||
accusedDriverId: p.accusedDriverId,
|
||||
raceName: r.track || 'Unknown Track',
|
||||
protestingDriver: data.drivers.find(d => d.id === p.protestingDriverId)?.name || 'Unknown',
|
||||
accusedDriver: data.drivers.find(d => d.id === p.accusedDriverId)?.name || 'Unknown',
|
||||
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]);
|
||||
status: p.status as 'pending' | 'under_review' | 'resolved' | 'rejected',
|
||||
})));
|
||||
}, [data.races, data.drivers]);
|
||||
|
||||
const allResolvedProtests = useMemo(() => {
|
||||
return data.races.flatMap(r => r.resolvedProtests.map(p => new ProtestViewModel({
|
||||
@@ -131,84 +127,91 @@ export function StewardingPageClient({ data, leagueId, currentDriverId, onRefetc
|
||||
});
|
||||
};
|
||||
|
||||
const handleReviewProtest = (id: string) => {
|
||||
// Find the protest in the data
|
||||
let foundProtest: ProtestViewModel | null = null;
|
||||
data.races.forEach(r => {
|
||||
const p = r.pendingProtests.find(p => p.id === id);
|
||||
if (p) {
|
||||
foundProtest = 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);
|
||||
}
|
||||
});
|
||||
if (foundProtest) setSelectedProtest(foundProtest);
|
||||
};
|
||||
|
||||
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>
|
||||
<StewardingStats
|
||||
totalPending={data.totalPending}
|
||||
totalResolved={data.totalResolved}
|
||||
totalPenalties={data.totalPenalties}
|
||||
/>
|
||||
|
||||
{/* Tab navigation */}
|
||||
<Box borderBottom borderColor="border-charcoal-outline">
|
||||
<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>
|
||||
|
||||
{/* 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
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
{/* Content */}
|
||||
{activeTab === 'pending' ? (
|
||||
<StewardingQueuePanel
|
||||
protests={allPendingProtests}
|
||||
onReview={handleReviewProtest}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<Box p={6}>
|
||||
<PenaltyHistoryList
|
||||
protests={allResolvedProtests}
|
||||
races={racesMap}
|
||||
drivers={driverMap}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
'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 React from 'react';
|
||||
import { WalletSummaryPanel } from '@/components/leagues/WalletSummaryPanel';
|
||||
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
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/ui/Button';
|
||||
|
||||
interface WalletTemplateProps {
|
||||
viewData: LeagueWalletViewData;
|
||||
@@ -29,29 +21,15 @@ interface WalletTemplateProps {
|
||||
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;
|
||||
export function LeagueWalletPageClient({ viewData, onExport }: WalletTemplateProps) {
|
||||
// Map transactions to the format expected by WalletSummaryPanel
|
||||
const transactions = viewData.transactions.map(t => ({
|
||||
id: t.id,
|
||||
type: t.type === 'withdrawal' ? 'debit' : 'credit' as 'credit' | 'debit',
|
||||
amount: parseFloat(t.formattedAmount.replace(/[^0-9.-]+/g, '')),
|
||||
description: t.description,
|
||||
date: t.formattedDate,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Container size="lg" py={8}>
|
||||
@@ -61,314 +39,29 @@ export function LeagueWalletPageClient({ viewData, onWithdraw, onExport, mutatio
|
||||
<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>
|
||||
<Button variant="secondary" onClick={onExport}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<UIIcon icon={Download} size={4} />
|
||||
<Text>Export</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
</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>
|
||||
)}
|
||||
<WalletSummaryPanel
|
||||
balance={viewData.balance}
|
||||
currency="USD"
|
||||
transactions={transactions}
|
||||
onDeposit={() => {}} // Not implemented for leagues yet
|
||||
onWithdraw={() => {}} // Not implemented for leagues yet
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user