Files
gridpilot.gg/apps/website/app/leagues/LeaguesPageClient.tsx
2026-01-18 13:26:35 +01:00

281 lines
9.0 KiB
TypeScript

'use client';
import React, { useState } from 'react';
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
import { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import { routes } from '@/lib/routing/RouteConfig';
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
import { Box } from '@/ui/Box';
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 {
Flame,
Globe,
Plus,
Search,
Sparkles,
Target,
Trophy,
Users,
Flag,
Award,
Timer,
Clock,
type LucideIcon,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
// ============================================================================
// TYPES
// ============================================================================
type CategoryId =
| 'all'
| 'driver'
| 'team'
| 'nations'
| 'trophy'
| 'new'
| 'popular'
| 'openSlots'
| 'endurance'
| 'sprint';
interface Category {
id: CategoryId;
label: string;
icon: LucideIcon;
description: string;
filter: (league: LeaguesViewData['leagues'][number]) => boolean;
color?: string;
}
interface LeaguesTemplateProps {
viewData: LeaguesViewData;
}
// ============================================================================
// CATEGORIES
// ============================================================================
const CATEGORIES: Category[] = [
{
id: 'all',
label: 'All',
icon: Globe,
description: 'Browse all available leagues',
filter: () => true,
},
{
id: 'popular',
label: 'Popular',
icon: Flame,
description: 'Most active leagues right now',
filter: (league) => {
const fillRate = (league.usedDriverSlots ?? 0) / (league.maxDrivers ?? 1);
return fillRate > 0.7;
},
color: 'text-orange-400',
},
{
id: 'new',
label: 'New',
icon: Sparkles,
description: 'Fresh leagues looking for members',
filter: (league) => {
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
return new Date(league.createdAt) > oneWeekAgo;
},
color: 'text-green-500',
},
{
id: 'openSlots',
label: 'Open Slots',
icon: Target,
description: 'Leagues with available spots',
filter: (league) => {
if (league.maxTeams && league.maxTeams > 0) {
const usedTeams = league.usedTeamSlots ?? 0;
return usedTeams < league.maxTeams;
}
const used = league.usedDriverSlots ?? 0;
const max = league.maxDrivers ?? 0;
return max > 0 && used < max;
},
color: 'text-cyan-400',
},
{
id: 'driver',
label: 'Driver',
icon: Trophy,
description: 'Compete as an individual',
filter: (league) => league.scoring?.primaryChampionshipType === 'driver',
},
{
id: 'team',
label: 'Team',
icon: Users,
description: 'Race together as a team',
filter: (league) => league.scoring?.primaryChampionshipType === 'team',
},
{
id: 'nations',
label: 'Nations',
icon: Flag,
description: 'Represent your country',
filter: (league) => league.scoring?.primaryChampionshipType === 'nations',
},
{
id: 'trophy',
label: 'Trophy',
icon: Award,
description: 'Special championship events',
filter: (league) => league.scoring?.primaryChampionshipType === 'trophy',
},
{
id: 'endurance',
label: 'Endurance',
icon: Timer,
description: 'Long-distance racing',
filter: (league) =>
league.scoring?.scoringPresetId?.includes('endurance') ??
league.timingSummary?.includes('h Race') ??
false,
},
{
id: 'sprint',
label: 'Sprint',
icon: Clock,
description: 'Quick, intense races',
filter: (league) =>
(league.scoring?.scoringPresetId?.includes('sprint') ?? false) &&
!(league.scoring?.scoringPresetId?.includes('endurance') ?? false),
},
];
export function LeaguesPageClient({ viewData }: LeaguesTemplateProps) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
const [activeCategory, setActiveCategory] = useState<CategoryId>('all');
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);
return matchesSearch && matchesCategory;
});
return (
<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>
<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>
{/* 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}
league={league as unknown as LeagueSummaryViewModel}
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>
);
}