website refactor
This commit is contained in:
183
apps/website/components/leagues/LeagueCard.tsx
Normal file
183
apps/website/components/leagues/LeagueCard.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Trophy, Users, Calendar, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface LeagueCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
coverUrl: string;
|
||||
logoUrl?: string;
|
||||
gameName?: string;
|
||||
memberCount: number;
|
||||
maxMembers?: number;
|
||||
nextRaceDate?: string;
|
||||
championshipType: 'driver' | 'team' | 'nations' | 'trophy';
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function LeagueCard({
|
||||
name,
|
||||
description,
|
||||
coverUrl,
|
||||
logoUrl,
|
||||
gameName,
|
||||
memberCount,
|
||||
maxMembers,
|
||||
nextRaceDate,
|
||||
championshipType,
|
||||
onClick,
|
||||
}: LeagueCardProps) {
|
||||
const fillPercentage = maxMembers ? (memberCount / maxMembers) * 100 : 0;
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="article"
|
||||
onClick={onClick}
|
||||
position="relative"
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
overflow="hidden"
|
||||
border
|
||||
borderColor="zinc-800"
|
||||
bg="zinc-900/50"
|
||||
hoverBorderColor="blue-500/30"
|
||||
hoverBg="zinc-900"
|
||||
transition
|
||||
cursor="pointer"
|
||||
group
|
||||
>
|
||||
{/* Cover Image */}
|
||||
<Box position="relative" h="32" overflow="hidden">
|
||||
<Box fullWidth fullHeight opacity={0.6}>
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={`${name} cover`}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</Box>
|
||||
<Box position="absolute" inset="0" bg="linear-gradient(to top, #09090b, transparent)" />
|
||||
|
||||
{/* Game Badge */}
|
||||
{gameName && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="3"
|
||||
left="3"
|
||||
px={2}
|
||||
py={1}
|
||||
bg="zinc-900/80"
|
||||
border
|
||||
borderColor="white/10"
|
||||
blur="sm"
|
||||
>
|
||||
<Text weight="bold" color="text-zinc-300" uppercase letterSpacing="0.05em" fontSize="10px">
|
||||
{gameName}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Championship Icon */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="3"
|
||||
right="3"
|
||||
p={1.5}
|
||||
bg="zinc-900/80"
|
||||
color="text-zinc-400"
|
||||
border
|
||||
borderColor="white/10"
|
||||
blur="sm"
|
||||
>
|
||||
{championshipType === 'driver' && <Trophy size={14} />}
|
||||
{championshipType === 'team' && <Users size={14} />}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box position="relative" display="flex" flexDirection="col" flexGrow={1} p={4} pt={6}>
|
||||
{/* Logo */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-6"
|
||||
left="4"
|
||||
w="12"
|
||||
h="12"
|
||||
border
|
||||
borderColor="zinc-800"
|
||||
bg="zinc-950"
|
||||
shadow="xl"
|
||||
overflow="hidden"
|
||||
>
|
||||
{logoUrl ? (
|
||||
<Image src={logoUrl} alt={`${name} logo`} fullWidth fullHeight objectFit="cover" />
|
||||
) : (
|
||||
<Box fullWidth fullHeight display="flex" alignItems="center" justifyContent="center" bg="zinc-900" color="text-zinc-700">
|
||||
<Trophy size={20} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box display="flex" flexDirection="col" gap={1} mb={4}>
|
||||
<Heading level={3} fontSize="lg" weight="bold" color="text-white"
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="group-hover:text-blue-400 transition-colors truncate"
|
||||
>
|
||||
{name}
|
||||
</Heading>
|
||||
<Text size="xs" color="text-zinc-500" lineClamp={2} leading="relaxed" h="8">
|
||||
{description || 'No description available'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Stats */}
|
||||
<Box display="flex" flexDirection="col" gap={3} mt="auto">
|
||||
<Box display="flex" flexDirection="col" gap={1.5}>
|
||||
<Box display="flex" justifyContent="between">
|
||||
<Text weight="bold" color="text-zinc-500" uppercase letterSpacing="widest" fontSize="10px">Drivers</Text>
|
||||
<Text color="text-zinc-400" font="mono" fontSize="10px">{memberCount}/{maxMembers || '∞'}</Text>
|
||||
</Box>
|
||||
<Box h="1" bg="zinc-800" overflow="hidden">
|
||||
<Box
|
||||
h="full"
|
||||
transition
|
||||
bg={fillPercentage > 90 ? 'bg-amber-500' : 'bg-blue-500'}
|
||||
w={`${Math.min(fillPercentage, 100)}%`}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" alignItems="center" justifyContent="between" pt={3} borderTop borderColor="zinc-800/50">
|
||||
<Box display="flex" alignItems="center" gap={2} color="text-zinc-500">
|
||||
<Calendar size={12} />
|
||||
<Text weight="bold" uppercase font="mono" fontSize="10px">
|
||||
{nextRaceDate || 'TBD'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={1} color="text-zinc-500"
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<Text weight="bold" uppercase letterSpacing="widest" fontSize="10px">View</Text>
|
||||
<Box
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="transition-transform group-hover:translate-x-0.5"
|
||||
>
|
||||
<ChevronRight size={12} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import React from 'react';
|
||||
import { MembershipStatus } from './MembershipStatus';
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { LeagueHeader as UiLeagueHeader } from '@/ui/LeagueHeader';
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { MembershipStatus } from './MembershipStatus';
|
||||
|
||||
// Main sponsor info for "by XYZ" display
|
||||
interface MainSponsorInfo {
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
@@ -27,33 +29,61 @@ export function LeagueHeader({
|
||||
description,
|
||||
mainSponsor,
|
||||
}: LeagueHeaderProps) {
|
||||
const logoUrl = getMediaUrl('league-logo', leagueId);
|
||||
|
||||
return (
|
||||
<UiLeagueHeader
|
||||
name={leagueName}
|
||||
description={description}
|
||||
logoUrl={logoUrl}
|
||||
statusContent={<MembershipStatus leagueId={leagueId} />}
|
||||
sponsorContent={
|
||||
mainSponsor ? (
|
||||
mainSponsor.websiteUrl ? (
|
||||
<Box
|
||||
as="a"
|
||||
href={mainSponsor.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
color="text-primary-blue"
|
||||
hoverTextColor="text-primary-blue/80"
|
||||
transition
|
||||
>
|
||||
{mainSponsor.name}
|
||||
</Box>
|
||||
) : (
|
||||
<Text color="text-primary-blue">{mainSponsor.name}</Text>
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<Box as="header" mb={8}>
|
||||
<Stack direction="row" align="center" gap={6}>
|
||||
<Box
|
||||
position="relative"
|
||||
w="20"
|
||||
h="20"
|
||||
overflow="hidden"
|
||||
border
|
||||
borderColor="white/10"
|
||||
bg="zinc-900"
|
||||
shadow="2xl"
|
||||
>
|
||||
<Image
|
||||
src={`/api/media/league-logo/${leagueId}`}
|
||||
alt={`${leagueName} logo`}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
<Stack gap={1}>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Heading level={1} fontSize="3xl" weight="bold" color="text-white">
|
||||
{leagueName}
|
||||
{mainSponsor && (
|
||||
<Text ml={3} size="lg" weight="normal" color="text-zinc-500">
|
||||
by{' '}
|
||||
{mainSponsor.websiteUrl ? (
|
||||
<Box
|
||||
as="a"
|
||||
href={mainSponsor.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
color="text-blue-500"
|
||||
hoverTextColor="text-blue-400"
|
||||
transition
|
||||
>
|
||||
{mainSponsor.name}
|
||||
</Box>
|
||||
) : (
|
||||
<Text color="text-blue-500">{mainSponsor.name}</Text>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Heading>
|
||||
<MembershipStatus leagueId={leagueId} />
|
||||
</Stack>
|
||||
{description && (
|
||||
<Text color="text-zinc-400" size="sm" maxWidth="2xl" block leading="relaxed">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
85
apps/website/components/leagues/LeagueHeaderPanel.tsx
Normal file
85
apps/website/components/leagues/LeagueHeaderPanel.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Trophy, Users, Timer, Activity, type LucideIcon } from 'lucide-react';
|
||||
import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData';
|
||||
|
||||
interface LeagueHeaderPanelProps {
|
||||
viewData: LeagueDetailViewData;
|
||||
}
|
||||
|
||||
export function LeagueHeaderPanel({ viewData }: LeagueHeaderPanelProps) {
|
||||
return (
|
||||
<Surface variant="dark" border rounded="lg" padding={6} position="relative" overflow="hidden">
|
||||
{/* Background Accent */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
right={0}
|
||||
w="300px"
|
||||
h="100%"
|
||||
bg="bg-gradient-to-l from-primary-blue/5 to-transparent"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
<Stack direction={{ base: 'col', md: 'row' }} justify="between" align="center" gap={6}>
|
||||
<Stack gap={2}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box p={2} bg="bg-primary-blue/10" rounded="md" border borderColor="border-primary-blue/20">
|
||||
<Icon icon={Trophy} size={6} color="text-primary-blue" />
|
||||
</Box>
|
||||
<Heading level={1} letterSpacing="tight">
|
||||
{viewData.name}
|
||||
</Heading>
|
||||
</Stack>
|
||||
<Text color="text-gray-400" size="sm" maxWidth="42rem">
|
||||
{viewData.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" gap={8} wrap>
|
||||
<StatItem
|
||||
icon={Users}
|
||||
label="Members"
|
||||
value={viewData.info.membersCount.toString()}
|
||||
color="text-primary-blue"
|
||||
/>
|
||||
<StatItem
|
||||
icon={Timer}
|
||||
label="Races"
|
||||
value={viewData.info.racesCount.toString()}
|
||||
color="text-neon-aqua"
|
||||
/>
|
||||
<StatItem
|
||||
icon={Activity}
|
||||
label="Avg SOF"
|
||||
value={(viewData.info.avgSOF ?? 0).toString()}
|
||||
color="text-performance-green"
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({ icon, label, value, color }: { icon: LucideIcon, label: string, value: string, color: string }) {
|
||||
return (
|
||||
<Stack gap={1}>
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Icon icon={icon} size={3.5} color="text-gray-500" />
|
||||
<Text size="xs" color="text-gray-500" weight="medium" letterSpacing="wider" display="block">
|
||||
{label.toUpperCase()}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Text size="xl" weight="bold" color={color} lineHeight="none">
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
59
apps/website/components/leagues/LeagueNavTabs.tsx
Normal file
59
apps/website/components/leagues/LeagueNavTabs.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Link } from '@/ui/Link';
|
||||
|
||||
interface Tab {
|
||||
label: string;
|
||||
href: string;
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
interface LeagueNavTabsProps {
|
||||
tabs: Tab[];
|
||||
currentPathname: string;
|
||||
}
|
||||
|
||||
export function LeagueNavTabs({ tabs, currentPathname }: LeagueNavTabsProps) {
|
||||
return (
|
||||
<Box as="nav" borderBottom borderColor="zinc-800" mb={6}>
|
||||
<Stack as="ul" direction="row" gap={8} overflow="auto" hideScrollbar>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.exact
|
||||
? currentPathname === tab.href
|
||||
: currentPathname.startsWith(tab.href);
|
||||
|
||||
return (
|
||||
<Box as="li" key={tab.href} position="relative">
|
||||
<Link
|
||||
href={tab.href}
|
||||
variant="ghost"
|
||||
pb={4}
|
||||
display="block"
|
||||
size="sm"
|
||||
weight="medium"
|
||||
color={isActive ? 'text-blue-500' : 'text-zinc-400'}
|
||||
hoverTextColor={isActive ? 'text-blue-500' : 'text-zinc-200'}
|
||||
transition
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
{isActive && (
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="0"
|
||||
left="0"
|
||||
right="0"
|
||||
h="0.5"
|
||||
bg="bg-blue-500"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
53
apps/website/components/leagues/LeagueRulesPanel.tsx
Normal file
53
apps/website/components/leagues/LeagueRulesPanel.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Shield, Info } from 'lucide-react';
|
||||
|
||||
interface Rule {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface LeagueRulesPanelProps {
|
||||
rules: Rule[];
|
||||
}
|
||||
|
||||
export function LeagueRulesPanel({ rules }: LeagueRulesPanelProps) {
|
||||
return (
|
||||
<Box as="section">
|
||||
<Stack gap={8}>
|
||||
<Box display="flex" alignItems="start" gap={4} p={4} bg="blue-500/5" border borderColor="blue-500/20">
|
||||
<Box color="text-blue-500" mt={0.5}><Info size={20} /></Box>
|
||||
<Stack gap={1}>
|
||||
<Text size="sm" weight="bold" color="text-blue-500" uppercase letterSpacing="0.05em">Code of Conduct</Text>
|
||||
<Text size="sm" color="text-zinc-400" leading="relaxed">
|
||||
All drivers are expected to maintain a high standard of sportsmanship.
|
||||
Intentional wrecking or abusive behavior will result in immediate disqualification.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box display="grid" responsiveGridCols={{ base: 1, md: 2 }} gap={6}>
|
||||
{rules.map((rule) => (
|
||||
<Box as="article" key={rule.id} display="flex" flexDirection="col" gap={3} p={6} border borderColor="zinc-800" bg="zinc-900/30">
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box p={2} bg="zinc-800" color="text-zinc-400">
|
||||
<Shield size={18} />
|
||||
</Box>
|
||||
<Heading level={3} fontSize="md" weight="bold" color="text-white">{rule.title}</Heading>
|
||||
</Box>
|
||||
<Text size="sm" color="text-zinc-400" leading="relaxed">
|
||||
{rule.content}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
93
apps/website/components/leagues/LeagueSchedulePanel.tsx
Normal file
93
apps/website/components/leagues/LeagueSchedulePanel.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { MapPin, Clock } from 'lucide-react';
|
||||
|
||||
interface RaceEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
trackName: string;
|
||||
date: string;
|
||||
time: string;
|
||||
status: 'upcoming' | 'live' | 'completed';
|
||||
}
|
||||
|
||||
interface LeagueSchedulePanelProps {
|
||||
events: RaceEvent[];
|
||||
}
|
||||
|
||||
export function LeagueSchedulePanel({ events }: LeagueSchedulePanelProps) {
|
||||
return (
|
||||
<Box as="section">
|
||||
<Stack gap={4}>
|
||||
{events.map((event) => (
|
||||
<Box
|
||||
as="article"
|
||||
key={event.id}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={6}
|
||||
p={4}
|
||||
border
|
||||
borderColor="zinc-800"
|
||||
bg="zinc-900/50"
|
||||
hoverBorderColor="zinc-700"
|
||||
transition
|
||||
>
|
||||
<Box display="flex" flexDirection="col" alignItems="center" justifyContent="center" w="16" h="16" borderRight borderColor="zinc-800" pr={6}>
|
||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase>
|
||||
{new Date(event.date).toLocaleDateString('en-US', { month: 'short' })}
|
||||
</Text>
|
||||
<Text size="2xl" weight="bold" color="text-white">
|
||||
{new Date(event.date).toLocaleDateString('en-US', { day: 'numeric' })}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexGrow={1}>
|
||||
<Heading level={3} fontSize="lg" weight="bold" color="text-white">{event.title}</Heading>
|
||||
<Stack direction="row" gap={4} mt={1}>
|
||||
<Box display="flex" alignItems="center" gap={1.5}>
|
||||
<Box color="text-zinc-600"><MapPin size={14} /></Box>
|
||||
<Text size="sm" color="text-zinc-400">{event.trackName}</Text>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={1.5}>
|
||||
<Box color="text-zinc-600"><Clock size={14} /></Box>
|
||||
<Text size="sm" color="text-zinc-400">{event.time}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
{event.status === 'live' && (
|
||||
<Box display="flex" alignItems="center" gap={1.5} px={2} py={1} bg="red-500/10" border borderColor="red-500/20">
|
||||
<Box w="1.5" h="1.5" rounded="full" bg="bg-red-500" animate="pulse" />
|
||||
<Text size="xs" weight="bold" color="text-red-500" uppercase letterSpacing="0.05em">
|
||||
Live
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{event.status === 'upcoming' && (
|
||||
<Box px={2} py={1} bg="blue-500/10" border borderColor="blue-500/20">
|
||||
<Text size="xs" weight="bold" color="text-blue-500" uppercase letterSpacing="0.05em">
|
||||
Upcoming
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{event.status === 'completed' && (
|
||||
<Box px={2} py={1} bg="zinc-800" border borderColor="zinc-700">
|
||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="0.05em">
|
||||
Results
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
147
apps/website/components/leagues/LeagueSlider.tsx
Normal file
147
apps/website/components/leagues/LeagueSlider.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
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 { Icon } from '@/ui/Icon';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { ChevronLeft, ChevronRight, type LucideIcon } from 'lucide-react';
|
||||
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
|
||||
import { LeagueSummaryViewModelBuilder } from '@/lib/builders/view-models/LeagueSummaryViewModelBuilder';
|
||||
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
interface LeagueSliderProps {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
description: string;
|
||||
leagues: LeaguesViewData['leagues'];
|
||||
autoScroll?: boolean;
|
||||
iconColor?: string;
|
||||
scrollSpeedMultiplier?: number;
|
||||
scrollDirection?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export function LeagueSlider({
|
||||
title,
|
||||
icon: IconComp,
|
||||
description,
|
||||
leagues,
|
||||
iconColor = 'text-primary-blue',
|
||||
}: LeagueSliderProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(true);
|
||||
|
||||
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;
|
||||
scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
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">
|
||||
<Icon icon={IconComp} 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>
|
||||
|
||||
{/* Navigation arrows */}
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => scroll('left')}
|
||||
disabled={!canScrollLeft}
|
||||
size="sm"
|
||||
w="2rem"
|
||||
h="2rem"
|
||||
>
|
||||
<Icon icon={ChevronLeft} size={4} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => scroll('right')}
|
||||
disabled={!canScrollRight}
|
||||
size="sm"
|
||||
w="2rem"
|
||||
h="2rem"
|
||||
>
|
||||
<Icon icon={ChevronRight} size={4} />
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Scrollable container with fade edges */}
|
||||
<Box position="relative">
|
||||
<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"
|
||||
/>
|
||||
<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}
|
||||
onScroll={checkScrollButtons}
|
||||
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">
|
||||
<Link href={routes.league.detail(league.id)} block>
|
||||
<LeagueCard league={viewModel} />
|
||||
</Link>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
80
apps/website/components/leagues/LeagueStandingsTable.tsx
Normal file
80
apps/website/components/leagues/LeagueStandingsTable.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface StandingEntry {
|
||||
position: number;
|
||||
driverName: string;
|
||||
teamName?: string;
|
||||
points: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
gap: string;
|
||||
}
|
||||
|
||||
interface LeagueStandingsTableProps {
|
||||
standings: StandingEntry[];
|
||||
}
|
||||
|
||||
export function LeagueStandingsTable({ standings }: LeagueStandingsTableProps) {
|
||||
return (
|
||||
<Box as="section" overflow="hidden" border borderColor="zinc-800" bg="zinc-900/50">
|
||||
<Box as="table" w="full" textAlign="left">
|
||||
<Box as="thead">
|
||||
<Box as="tr" borderBottom borderColor="zinc-800" bg="zinc-900/80">
|
||||
<Box as="th" px={4} py={3} w="12">
|
||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Pos</Text>
|
||||
</Box>
|
||||
<Box as="th" px={4} py={3}>
|
||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Driver</Text>
|
||||
</Box>
|
||||
<Box as="th" px={4} py={3} display={{ base: 'none', md: 'table-cell' }}>
|
||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Team</Text>
|
||||
</Box>
|
||||
<Box as="th" px={4} py={3} textAlign="center">
|
||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Wins</Text>
|
||||
</Box>
|
||||
<Box as="th" px={4} py={3} textAlign="center">
|
||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Podiums</Text>
|
||||
</Box>
|
||||
<Box as="th" px={4} py={3} textAlign="right">
|
||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Points</Text>
|
||||
</Box>
|
||||
<Box as="th" px={4} py={3} textAlign="right">
|
||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Gap</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box as="tbody">
|
||||
{standings.map((entry) => (
|
||||
<Box as="tr" key={entry.driverName} borderBottom borderColor="zinc-800" hoverBg="zinc-800/50" transition>
|
||||
<Box as="td" px={4} py={3}>
|
||||
<Text size="sm" color="text-zinc-400" font="mono">{entry.position}</Text>
|
||||
</Box>
|
||||
<Box as="td" px={4} py={3}>
|
||||
<Text size="sm" weight="medium" color="text-zinc-200">{entry.driverName}</Text>
|
||||
</Box>
|
||||
<Box as="td" px={4} py={3} display={{ base: 'none', md: 'table-cell' }}>
|
||||
<Text size="sm" color="text-zinc-500">{entry.teamName || '—'}</Text>
|
||||
</Box>
|
||||
<Box as="td" px={4} py={3} textAlign="center">
|
||||
<Text size="sm" color="text-zinc-400">{entry.wins}</Text>
|
||||
</Box>
|
||||
<Box as="td" px={4} py={3} textAlign="center">
|
||||
<Text size="sm" color="text-zinc-400">{entry.podiums}</Text>
|
||||
</Box>
|
||||
<Box as="td" px={4} py={3} textAlign="right">
|
||||
<Text size="sm" weight="bold" color="text-white">{entry.points}</Text>
|
||||
</Box>
|
||||
<Box as="td" px={4} py={3} textAlign="right">
|
||||
<Text size="sm" color="text-zinc-500" font="mono">{entry.gap}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
118
apps/website/components/leagues/ScheduleTable.tsx
Normal file
118
apps/website/components/leagues/ScheduleTable.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Calendar, MapPin, ChevronRight } from 'lucide-react';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
interface RaceEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
track: string;
|
||||
scheduledAt: string;
|
||||
status: 'upcoming' | 'live' | 'completed';
|
||||
}
|
||||
|
||||
interface ScheduleTableProps {
|
||||
races: RaceEntry[];
|
||||
}
|
||||
|
||||
export function ScheduleTable({ races }: ScheduleTableProps) {
|
||||
return (
|
||||
<Surface variant="dark" border rounded="lg" overflow="hidden">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Race</TableHeader>
|
||||
<TableHeader>Track</TableHeader>
|
||||
<TableHeader>Date</TableHeader>
|
||||
<TableHeader>Status</TableHeader>
|
||||
<TableHeader textAlign="right">Actions</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{races.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} textAlign="center" py={12}>
|
||||
<Text color="text-gray-500">No races scheduled yet.</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
races.map((race) => (
|
||||
<TableRow key={race.id}>
|
||||
<TableCell>
|
||||
<Text weight="medium" color="text-white">{race.name}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={MapPin} size={3.5} color="text-gray-500" />
|
||||
<Text size="sm" color="text-gray-400">{race.track}</Text>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Calendar} size={3.5} color="text-gray-500" />
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{new Date(race.scheduledAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={race.status} />
|
||||
</TableCell>
|
||||
<TableCell textAlign="right">
|
||||
<Link
|
||||
href={routes.race.detail(race.id)}
|
||||
variant="primary"
|
||||
size="xs"
|
||||
weight="bold"
|
||||
letterSpacing="wider"
|
||||
>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Text>DETAILS</Text>
|
||||
<Icon icon={ChevronRight} size={3} />
|
||||
</Stack>
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: RaceEntry['status'] }) {
|
||||
const styles = {
|
||||
upcoming: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
|
||||
live: 'bg-performance-green/10 text-performance-green border-performance-green/20 animate-pulse',
|
||||
completed: 'bg-primary-blue/10 text-primary-blue border-primary-blue/20',
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="span"
|
||||
px={2}
|
||||
py={0.5}
|
||||
rounded="full"
|
||||
fontSize="10px"
|
||||
border
|
||||
bg={styles[status].split(' ')[0]}
|
||||
color={styles[status].split(' ')[1]}
|
||||
borderColor={styles[status].split(' ')[2]}
|
||||
animate={status === 'live' ? 'pulse' : 'none'}
|
||||
letterSpacing="widest"
|
||||
weight="bold"
|
||||
display="inline-block"
|
||||
>
|
||||
{status.toUpperCase()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
118
apps/website/components/leagues/StandingsTableShell.tsx
Normal file
118
apps/website/components/leagues/StandingsTableShell.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
|
||||
import { Trophy, TrendingUp } from 'lucide-react';
|
||||
|
||||
interface StandingsEntry {
|
||||
position: number;
|
||||
driverName: string;
|
||||
points: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
change?: number;
|
||||
}
|
||||
|
||||
interface StandingsTableShellProps {
|
||||
standings: StandingsEntry[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function StandingsTableShell({ standings, title = 'Championship Standings' }: StandingsTableShellProps) {
|
||||
return (
|
||||
<Surface variant="dark" border rounded="lg" overflow="hidden">
|
||||
<Box px={6} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Trophy} size={4} color="text-warning-amber" />
|
||||
<Text weight="bold" letterSpacing="wider" size="sm" display="block">
|
||||
{title.toUpperCase()}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box px={2} py={0.5} rounded="md" bg="bg-charcoal-outline/50">
|
||||
<Text size="xs" color="text-gray-400" weight="medium">{standings.length} Drivers</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader w="4rem">Pos</TableHeader>
|
||||
<TableHeader>Driver</TableHeader>
|
||||
<TableHeader textAlign="center">Wins</TableHeader>
|
||||
<TableHeader textAlign="center">Podiums</TableHeader>
|
||||
<TableHeader textAlign="right">Points</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{standings.map((entry) => (
|
||||
<TableRow key={entry.driverName}>
|
||||
<TableCell>
|
||||
<PositionBadge position={entry.position} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Text weight="bold" color="text-white">{entry.driverName}</Text>
|
||||
{entry.change !== undefined && entry.change !== 0 && (
|
||||
<Stack direction="row" align="center" gap={0.5}>
|
||||
<Icon
|
||||
icon={TrendingUp}
|
||||
size={3}
|
||||
color={entry.change > 0 ? 'text-performance-green' : 'text-error-red'}
|
||||
transform={entry.change < 0 ? 'rotate(180deg)' : undefined}
|
||||
/>
|
||||
<Text size="xs" color={entry.change > 0 ? 'text-performance-green' : 'text-error-red'}>
|
||||
{Math.abs(entry.change)}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell textAlign="center">
|
||||
<Text size="sm" color={entry.wins > 0 ? 'text-white' : 'text-gray-500'}>{entry.wins}</Text>
|
||||
</TableCell>
|
||||
<TableCell textAlign="center">
|
||||
<Text size="sm" color={entry.podiums > 0 ? 'text-white' : 'text-gray-500'}>{entry.podiums}</Text>
|
||||
</TableCell>
|
||||
<TableCell textAlign="right">
|
||||
<Text weight="bold" color="text-primary-blue">{entry.points}</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
function PositionBadge({ position }: { position: number }) {
|
||||
const isPodium = position <= 3;
|
||||
const colors = {
|
||||
1: 'text-warning-amber bg-warning-amber/10 border-warning-amber/20',
|
||||
2: 'text-gray-300 bg-gray-300/10 border-gray-300/20',
|
||||
3: 'text-orange-400 bg-orange-400/10 border-orange-400/20',
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
center
|
||||
w={8}
|
||||
h={8}
|
||||
rounded="md"
|
||||
border={isPodium}
|
||||
bg={isPodium ? colors[position as keyof typeof colors].split(' ')[1] : undefined}
|
||||
color={isPodium ? colors[position as keyof typeof colors].split(' ')[0] : 'text-gray-500'}
|
||||
borderColor={isPodium ? colors[position as keyof typeof colors].split(' ')[2] : undefined}
|
||||
>
|
||||
<Text size="sm" weight="bold">
|
||||
{position}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
117
apps/website/components/leagues/StewardingQueuePanel.tsx
Normal file
117
apps/website/components/leagues/StewardingQueuePanel.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Clock, ShieldAlert, MessageSquare } from 'lucide-react';
|
||||
import { Button } from '@/ui/Button';
|
||||
|
||||
interface Protest {
|
||||
id: string;
|
||||
raceName: string;
|
||||
protestingDriver: string;
|
||||
accusedDriver: string;
|
||||
description: string;
|
||||
submittedAt: string;
|
||||
status: 'pending' | 'under_review' | 'resolved' | 'rejected';
|
||||
}
|
||||
|
||||
interface StewardingQueuePanelProps {
|
||||
protests: Protest[];
|
||||
onReview: (id: string) => void;
|
||||
}
|
||||
|
||||
export function StewardingQueuePanel({ protests, onReview }: StewardingQueuePanelProps) {
|
||||
return (
|
||||
<Surface variant="dark" border rounded="lg" overflow="hidden">
|
||||
<Box px={6} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={ShieldAlert} size={4} color="text-error-red" />
|
||||
<Text weight="bold" letterSpacing="wider" size="sm" display="block">
|
||||
STEWARDING QUEUE
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box px={2} py={0.5} rounded="md" bg="bg-error-red/10" border borderColor="border-error-red/20">
|
||||
<Text size="xs" color="text-error-red" weight="bold">
|
||||
{protests.filter(p => p.status === 'pending').length} Pending
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack gap={0}>
|
||||
{protests.length === 0 ? (
|
||||
<Box py={12} center>
|
||||
<Stack align="center" gap={3}>
|
||||
<Icon icon={ShieldAlert} size={8} color="text-gray-700" />
|
||||
<Text color="text-gray-500">No active protests in the queue.</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : (
|
||||
protests.map((protest) => (
|
||||
<Box key={protest.id} p={6} borderBottom borderColor="border-charcoal-outline" hoverBg="bg-white/5" transition>
|
||||
<Stack direction={{ base: 'col', md: 'row' }} justify="between" align="start" gap={4}>
|
||||
<Stack gap={3} flexGrow={1}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<StatusIndicator status={protest.status} />
|
||||
<Text size="xs" color="text-gray-500" weight="bold" letterSpacing="widest">
|
||||
{protest.raceName.toUpperCase()}
|
||||
</Text>
|
||||
<Box w={1} h={1} rounded="full" bg="bg-gray-700" />
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Icon icon={Clock} size={3} color="text-gray-600" />
|
||||
<Text size="xs" color="text-gray-500">
|
||||
{new Date(protest.submittedAt).toLocaleString()}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Stack direction="row" align="center" gap={2} wrap>
|
||||
<Text weight="bold" color="text-white">{protest.protestingDriver}</Text>
|
||||
<Text size="xs" color="text-gray-600" weight="bold">VS</Text>
|
||||
<Text weight="bold" color="text-white">{protest.accusedDriver}</Text>
|
||||
</Stack>
|
||||
<Text size="sm" color="text-gray-400" mt={2} lineClamp={2}>
|
||||
“{protest.description}”
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" align="center" gap={3} w={{ base: 'full', md: 'auto' }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onReview(protest.id)}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={MessageSquare} size={3.5} />
|
||||
<Text>Review</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIndicator({ status }: { status: Protest['status'] }) {
|
||||
const colors = {
|
||||
pending: 'bg-warning-amber',
|
||||
under_review: 'bg-primary-blue',
|
||||
resolved: 'bg-performance-green',
|
||||
rejected: 'bg-gray-500',
|
||||
};
|
||||
|
||||
return (
|
||||
<Box w={2} h={2} rounded="full" bg={colors[status]} animate={status === 'under_review' ? 'pulse' : 'none'} />
|
||||
);
|
||||
}
|
||||
128
apps/website/components/leagues/WalletSummaryPanel.tsx
Normal file
128
apps/website/components/leagues/WalletSummaryPanel.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Wallet, ArrowUpRight, ArrowDownLeft, History } from 'lucide-react';
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
type: 'credit' | 'debit';
|
||||
amount: number;
|
||||
description: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface WalletSummaryPanelProps {
|
||||
balance: number;
|
||||
currency: string;
|
||||
transactions: Transaction[];
|
||||
onDeposit: () => void;
|
||||
onWithdraw: () => void;
|
||||
}
|
||||
|
||||
export function WalletSummaryPanel({ balance, currency, transactions, onDeposit, onWithdraw }: WalletSummaryPanelProps) {
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Surface variant="dark" border rounded="lg" padding={8} position="relative" overflow="hidden">
|
||||
{/* Background Pattern */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-5rem"
|
||||
right="-5rem"
|
||||
w="12rem"
|
||||
h="12rem"
|
||||
bg="bg-primary-blue/5"
|
||||
rounded="full"
|
||||
blur="xl"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
<Stack direction={{ base: 'col', md: 'row' }} justify="between" align="center" gap={8}>
|
||||
<Stack gap={2}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Wallet} size={4} color="text-gray-500" />
|
||||
<Text size="xs" color="text-gray-500" weight="bold" letterSpacing="widest">AVAILABLE BALANCE</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" align="baseline" gap={2}>
|
||||
<Text size="4xl" weight="bold" color="text-white">
|
||||
{balance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</Text>
|
||||
<Text size="xl" weight="medium" color="text-gray-500">{currency}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" gap={3}>
|
||||
<Button variant="primary" onClick={onDeposit}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={ArrowDownLeft} size={4} />
|
||||
<Text>Deposit</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onWithdraw}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={ArrowUpRight} size={4} />
|
||||
<Text>Withdraw</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Surface>
|
||||
|
||||
<Surface variant="dark" border rounded="lg" overflow="hidden">
|
||||
<Box px={6} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={History} size={4} color="text-primary-blue" />
|
||||
<Text weight="bold" letterSpacing="wider" size="sm" display="block">
|
||||
RECENT TRANSACTIONS
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack gap={0}>
|
||||
{transactions.length === 0 ? (
|
||||
<Box py={12} center>
|
||||
<Text color="text-gray-500">No recent transactions.</Text>
|
||||
</Box>
|
||||
) : (
|
||||
transactions.map((tx) => (
|
||||
<Box key={tx.id} p={4} borderBottom borderColor="border-charcoal-outline" hoverBg="bg-white/5" transition>
|
||||
<Stack direction="row" justify="between" align="center">
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box
|
||||
center
|
||||
w={10}
|
||||
h={10}
|
||||
rounded="full"
|
||||
bg={tx.type === 'credit' ? 'bg-performance-green/10' : 'bg-error-red/10'}
|
||||
>
|
||||
<Icon
|
||||
icon={tx.type === 'credit' ? ArrowDownLeft : ArrowUpRight}
|
||||
size={4}
|
||||
color={tx.type === 'credit' ? 'text-performance-green' : 'text-error-red'}
|
||||
/>
|
||||
</Box>
|
||||
<Stack gap={0.5}>
|
||||
<Text weight="medium" color="text-white">{tx.description}</Text>
|
||||
<Text size="xs" color="text-gray-500">{new Date(tx.date).toLocaleDateString()}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Text
|
||||
weight="bold"
|
||||
color={tx.type === 'credit' ? 'text-performance-green' : 'text-white'}
|
||||
>
|
||||
{tx.type === 'credit' ? '+' : '-'}{tx.amount.toFixed(2)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Surface>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user