website refactor

This commit is contained in:
2026-01-17 15:46:55 +01:00
parent 4d5ce9bfd6
commit 72a626ce71
346 changed files with 19308 additions and 8605 deletions

View File

@@ -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 &quot;<Text color="text-primary-blue">{searchQuery}</Text>&quot;
</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>
);
}