267 lines
8.1 KiB
TypeScript
267 lines
8.1 KiB
TypeScript
import { ChevronRight, Users, Clock, Calendar, UserPlus, Heart } from 'lucide-react';
|
|
import { ReactNode } from 'react';
|
|
import { Box } from './Box';
|
|
import { Card } from './Card';
|
|
import { Icon } from './Icon';
|
|
import { Image } from './Image';
|
|
import { Text } from './Text';
|
|
import { Stack } from './Stack';
|
|
import { Group } from './Group';
|
|
import { Heading } from './Heading';
|
|
import { Button } from './Button';
|
|
import { Badge } from './Badge';
|
|
|
|
export interface LeagueCardProps {
|
|
name: string;
|
|
description?: string;
|
|
coverUrl: string;
|
|
logoUrl?: string;
|
|
slotLabel: string;
|
|
usedSlots: number;
|
|
maxSlots: number | string;
|
|
fillPercentage: number;
|
|
hasOpenSlots: boolean;
|
|
openSlotsCount: number;
|
|
isTeamLeague: boolean;
|
|
usedDriverSlots?: number;
|
|
maxDrivers?: number;
|
|
activeDriversCount?: number;
|
|
nextRaceAt?: string;
|
|
timingSummary?: string;
|
|
onClick?: () => void;
|
|
onQuickJoin?: (e: React.MouseEvent) => void;
|
|
onFollow?: (e: React.MouseEvent) => void;
|
|
badges?: ReactNode;
|
|
championshipBadge?: ReactNode;
|
|
isFeatured?: boolean;
|
|
}
|
|
|
|
export const LeagueCard = ({
|
|
name,
|
|
description,
|
|
coverUrl,
|
|
logoUrl,
|
|
slotLabel,
|
|
usedSlots,
|
|
maxSlots,
|
|
fillPercentage,
|
|
hasOpenSlots,
|
|
openSlotsCount,
|
|
isTeamLeague,
|
|
usedDriverSlots,
|
|
maxDrivers,
|
|
activeDriversCount,
|
|
nextRaceAt,
|
|
timingSummary,
|
|
onClick,
|
|
onQuickJoin,
|
|
onFollow,
|
|
badges,
|
|
championshipBadge,
|
|
isFeatured
|
|
}: LeagueCardProps) => {
|
|
return (
|
|
<Card
|
|
variant="precision"
|
|
onClick={onClick}
|
|
fullHeight
|
|
padding="none"
|
|
data-testid="league-card"
|
|
>
|
|
<Box height="8rem" position="relative" overflow="hidden">
|
|
<Image
|
|
src={coverUrl}
|
|
alt={name}
|
|
fullWidth
|
|
fullHeight
|
|
objectFit="cover"
|
|
style={{ opacity: 0.4, filter: 'grayscale(0.2)' }}
|
|
/>
|
|
<Box
|
|
position="absolute"
|
|
inset={0}
|
|
style={{ background: 'linear-gradient(to top, var(--ui-color-bg-base), transparent)' }}
|
|
/>
|
|
<Box position="absolute" top={3} left={3}>
|
|
<Group gap={2}>
|
|
{isFeatured && (
|
|
<Badge variant="warning" size="sm" icon={Heart}>FEATURED</Badge>
|
|
)}
|
|
{badges}
|
|
</Group>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Stack padding={5} gap={5} flex={1}>
|
|
<Stack direction="row" align="start" gap={4} marginTop="-2.5rem" position="relative" zIndex={10}>
|
|
<Box
|
|
width="4rem"
|
|
height="4rem"
|
|
bg="var(--ui-color-bg-surface)"
|
|
rounded="lg"
|
|
border
|
|
borderColor="var(--ui-color-border-default)"
|
|
overflow="hidden"
|
|
display="flex"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
>
|
|
{logoUrl ? (
|
|
<Image src={logoUrl} alt={name} fullWidth fullHeight objectFit="cover" />
|
|
) : (
|
|
<Icon icon={Users} size={6} intent="low" />
|
|
)}
|
|
</Box>
|
|
<Stack flex={1} gap={1} paddingTop="2.5rem">
|
|
<Heading level={4} weight="bold" uppercase letterSpacing="tight" data-testid="league-card-title">
|
|
{name}
|
|
</Heading>
|
|
{championshipBadge}
|
|
</Stack>
|
|
</Stack>
|
|
|
|
{description && (
|
|
<Text size="xs" variant="low" lineClamp={2} block leading="relaxed">
|
|
{description}
|
|
</Text>
|
|
)}
|
|
|
|
<Stack gap={2}>
|
|
{nextRaceAt && (
|
|
<Group gap={2} align="center" data-testid="league-card-next-race">
|
|
<Icon icon={Calendar} size={3} intent="primary" />
|
|
<Text size="xs" variant="high" weight="bold">
|
|
Next: {new Date(nextRaceAt).toLocaleDateString()} {new Date(nextRaceAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
</Text>
|
|
</Group>
|
|
)}
|
|
{activeDriversCount !== undefined && activeDriversCount > 0 && (
|
|
<Group gap={2} align="center" data-testid="league-card-active-drivers">
|
|
<Icon icon={Users} size={3} intent="success" />
|
|
<Text size="xs" variant="success" weight="bold">
|
|
{activeDriversCount} Active Drivers
|
|
</Text>
|
|
</Group>
|
|
)}
|
|
</Stack>
|
|
|
|
<Box flex={1} />
|
|
|
|
<Stack gap={4}>
|
|
<Stack gap={2}>
|
|
<Group justify="between" align="end">
|
|
<Text size="xs" variant="low" weight="bold" uppercase letterSpacing="widest">{slotLabel}</Text>
|
|
<Text size="sm" variant="high" font="mono" weight="bold">{usedSlots} / {maxSlots}</Text>
|
|
</Group>
|
|
<Box height="2px" bg="var(--ui-color-bg-surface-muted)" rounded="full" overflow="hidden">
|
|
<Box
|
|
height="100%"
|
|
bg="var(--ui-color-intent-primary)"
|
|
style={{
|
|
width: `${Math.min(fillPercentage, 100)}%`,
|
|
boxShadow: `0 0 8px var(--ui-color-intent-primary)44`
|
|
}}
|
|
/>
|
|
</Box>
|
|
</Stack>
|
|
|
|
<Group gap={2} fullWidth>
|
|
{onQuickJoin && (
|
|
<Button
|
|
size="xs"
|
|
variant="primary"
|
|
fullWidth
|
|
onClick={(e) => { e.stopPropagation(); onQuickJoin(e); }}
|
|
icon={<Icon icon={UserPlus} size={3} />}
|
|
data-testid="quick-join-button"
|
|
>
|
|
Join
|
|
</Button>
|
|
)}
|
|
{onFollow && (
|
|
<Button
|
|
size="xs"
|
|
variant="secondary"
|
|
fullWidth
|
|
onClick={(e) => { e.stopPropagation(); onFollow(e); }}
|
|
icon={<Icon icon={Heart} size={3} />}
|
|
>
|
|
Follow
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
|
|
<Stack direction="row" justify="between" align="center" paddingTop={3} style={{ borderTop: '1px solid var(--ui-color-border-muted)' }}>
|
|
<Stack direction="row" align="center" gap={1.5}>
|
|
<Icon icon={Clock} size={3} intent="low" />
|
|
<Text size="xs" variant="low" mono uppercase truncate maxWidth="12rem">
|
|
{timingSummary || 'Schedule TBD'}
|
|
</Text>
|
|
</Stack>
|
|
<Icon icon={ChevronRight} size={3} intent="primary" />
|
|
</Stack>
|
|
</Stack>
|
|
</Stack>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export interface LeagueCardStatsProps {
|
|
label: string;
|
|
value: string;
|
|
percentage: number;
|
|
intent?: 'primary' | 'success' | 'warning' | 'telemetry';
|
|
}
|
|
|
|
export const LeagueCardStats = ({ label, value, percentage, intent = 'primary' }: LeagueCardStatsProps) => {
|
|
const intentColors = {
|
|
primary: 'var(--ui-color-intent-primary)',
|
|
success: 'var(--ui-color-intent-success)',
|
|
warning: 'var(--ui-color-intent-warning)',
|
|
telemetry: 'var(--ui-color-intent-telemetry)',
|
|
};
|
|
|
|
return (
|
|
<Box marginBottom={6}>
|
|
<Stack gap={2}>
|
|
<Group justify="between" align="end">
|
|
<Text size="xs" variant="low" weight="bold" uppercase letterSpacing="widest">{label}</Text>
|
|
<Text size="sm" variant="high" font="mono" weight="bold">{value}</Text>
|
|
</Group>
|
|
<Box height="2px" bg="var(--ui-color-bg-surface-muted)" rounded="full" overflow="hidden">
|
|
<Box
|
|
height="100%"
|
|
bg={intentColors[intent]}
|
|
style={{
|
|
width: `${Math.min(percentage, 100)}%`,
|
|
boxShadow: `0 0 8px ${intentColors[intent]}44`
|
|
}}
|
|
/>
|
|
</Box>
|
|
</Stack>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export interface LeagueCardFooterProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
export const LeagueCardFooter = ({ children }: LeagueCardFooterProps) => (
|
|
<Box
|
|
marginTop="auto"
|
|
paddingTop={4}
|
|
style={{ borderTop: '1px solid var(--ui-color-border-muted)' }}
|
|
>
|
|
<Group justify="between" align="center">
|
|
<Box flex={1}>
|
|
{children}
|
|
</Box>
|
|
<Group gap={1} align="center">
|
|
<Text size="xs" variant="low" weight="bold" uppercase letterSpacing="widest">ACCESS</Text>
|
|
<Icon icon={ChevronRight} size={3} intent="low" />
|
|
</Group>
|
|
</Group>
|
|
</Box>
|
|
);
|