website refactor
This commit is contained in:
47
apps/website/components/races/LatestResultsSidebar.tsx
Normal file
47
apps/website/components/races/LatestResultsSidebar.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { RaceResultList } from '@/ui/RaceResultList';
|
||||
import { RaceSummaryItem } from '@/ui/RaceSummaryItem';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
type RaceWithResults = {
|
||||
raceId: string;
|
||||
track: string;
|
||||
car: string;
|
||||
winnerName: string;
|
||||
scheduledAt: string | Date;
|
||||
};
|
||||
|
||||
interface LatestResultsSidebarProps {
|
||||
results: RaceWithResults[];
|
||||
}
|
||||
|
||||
export function LatestResultsSidebar({ results }: LatestResultsSidebarProps) {
|
||||
if (!results.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card bg="bg-iron-gray/80" p={4}>
|
||||
<Heading level={3} mb={3}>
|
||||
Latest results
|
||||
</Heading>
|
||||
<RaceResultList>
|
||||
{results.slice(0, 4).map((result) => {
|
||||
const scheduledAt = typeof result.scheduledAt === 'string' ? new Date(result.scheduledAt) : result.scheduledAt;
|
||||
|
||||
return (
|
||||
<Box as="li" key={result.raceId}>
|
||||
<RaceSummaryItem
|
||||
track={result.track}
|
||||
meta={`${result.winnerName} • ${result.car}`}
|
||||
date={scheduledAt}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</RaceResultList>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
59
apps/website/components/races/LiveRacesBanner.tsx
Normal file
59
apps/website/components/races/LiveRacesBanner.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
|
||||
|
||||
import type { RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { LiveRaceItem } from '@/ui/LiveRaceItem';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface LiveRacesBannerProps {
|
||||
liveRaces: RaceViewData[];
|
||||
onRaceClick: (raceId: string) => void;
|
||||
}
|
||||
|
||||
export function LiveRacesBanner({ liveRaces, onRaceClick }: LiveRacesBannerProps) {
|
||||
if (liveRaces.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
rounded="xl"
|
||||
p={6}
|
||||
border
|
||||
borderColor="border-performance-green/30"
|
||||
bg="linear-gradient(to right, rgba(16, 185, 129, 0.2), rgba(16, 185, 129, 0.1), transparent)"
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
right="0"
|
||||
w="32"
|
||||
h="32"
|
||||
bg="bg-performance-green/20"
|
||||
rounded="full"
|
||||
blur="xl"
|
||||
/>
|
||||
|
||||
<Box position="relative" zIndex={10}>
|
||||
<Box mb={4}>
|
||||
<Stack direction="row" align="center" gap={2} bg="bg-performance-green/20" px={3} py={1} rounded="full" w="fit">
|
||||
<Box w="2" h="2" bg="bg-performance-green" rounded="full" />
|
||||
<Text weight="semibold" size="sm" color="text-performance-green">LIVE NOW</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack gap={3}>
|
||||
{liveRaces.map((race) => (
|
||||
<LiveRaceItem
|
||||
key={race.id}
|
||||
track={race.track}
|
||||
leagueName={race.leagueName ?? 'Unknown League'}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
30
apps/website/components/races/NextRaceCardWrapper.tsx
Normal file
30
apps/website/components/races/NextRaceCardWrapper.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { NextRaceCard as UiNextRaceCard } from '@/ui/NextRaceCard';
|
||||
|
||||
interface NextRaceCardProps {
|
||||
nextRace: {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
formattedDate: string;
|
||||
formattedTime: string;
|
||||
timeUntil: string;
|
||||
isMyLeague: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function NextRaceCard({ nextRace }: NextRaceCardProps) {
|
||||
return (
|
||||
<UiNextRaceCard
|
||||
track={nextRace.track}
|
||||
car={nextRace.car}
|
||||
formattedDate={nextRace.formattedDate}
|
||||
formattedTime={nextRace.formattedTime}
|
||||
timeUntil={nextRace.timeUntil}
|
||||
isMyLeague={nextRace.isMyLeague}
|
||||
href={routes.race.detail(nextRace.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
45
apps/website/components/races/RaceCardWrapper.tsx
Normal file
45
apps/website/components/races/RaceCardWrapper.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { raceStatusConfig } from '@/lib/utilities/raceStatus';
|
||||
import { RaceCard as UiRaceCard } from '@/ui/RaceCard';
|
||||
|
||||
interface RaceCardProps {
|
||||
race: {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: string;
|
||||
leagueId?: string;
|
||||
leagueName: string;
|
||||
strengthOfField?: number | null;
|
||||
};
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function RaceCard({ race, onClick }: RaceCardProps) {
|
||||
const config = raceStatusConfig[race.status as keyof typeof raceStatusConfig] || {
|
||||
border: 'border-charcoal-outline',
|
||||
bg: 'bg-charcoal-outline',
|
||||
color: 'text-gray-400',
|
||||
icon: null,
|
||||
label: 'Scheduled',
|
||||
};
|
||||
|
||||
return (
|
||||
<UiRaceCard
|
||||
track={race.track}
|
||||
car={race.car}
|
||||
scheduledAt={race.scheduledAt}
|
||||
status={race.status}
|
||||
leagueName={race.leagueName}
|
||||
leagueId={race.leagueId}
|
||||
strengthOfField={race.strengthOfField}
|
||||
onClick={onClick}
|
||||
statusConfig={{
|
||||
...config,
|
||||
icon: config.icon as LucideIcon | null,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
61
apps/website/components/races/RaceEntryList.tsx
Normal file
61
apps/website/components/races/RaceEntryList.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
|
||||
|
||||
import { Card } from '@/ui/Card';
|
||||
import { DriverEntryRow } from '@/components/drivers/DriverEntryRow';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
interface Entry {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
country: string;
|
||||
rating?: number | null;
|
||||
isCurrentUser: boolean;
|
||||
}
|
||||
|
||||
interface RaceEntryListProps {
|
||||
entries: Entry[];
|
||||
onDriverClick: (driverId: string) => void;
|
||||
}
|
||||
|
||||
export function RaceEntryList({ entries, onDriverClick }: RaceEntryListProps) {
|
||||
return (
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading level={2} icon={<Icon icon={Users} size={5} color="rgb(59, 130, 246)" />}>Entry List</Heading>
|
||||
<Text size="sm" color="text-gray-400">{entries.length} drivers</Text>
|
||||
</Stack>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<Stack align="center" py={8} gap={3}>
|
||||
<Surface variant="muted" rounded="full" p={4}>
|
||||
<Icon icon={Users} size={6} color="rgb(82, 82, 82)" />
|
||||
</Surface>
|
||||
<Text color="text-gray-400">No drivers registered yet</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={1}>
|
||||
{entries.map((driver, index) => (
|
||||
<DriverEntryRow
|
||||
key={driver.id}
|
||||
index={index}
|
||||
name={driver.name}
|
||||
avatarUrl={driver.avatarUrl}
|
||||
country={driver.country}
|
||||
rating={driver.rating}
|
||||
isCurrentUser={driver.isCurrentUser}
|
||||
onClick={() => onDriverClick(driver.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
64
apps/website/components/races/RaceFilterBar.tsx
Normal file
64
apps/website/components/races/RaceFilterBar.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
|
||||
|
||||
import type { TimeFilter } from '@/templates/RacesTemplate';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { FilterGroup } from '@/ui/FilterGroup';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
|
||||
interface RaceFilterBarProps {
|
||||
timeFilter: TimeFilter;
|
||||
setTimeFilter: (filter: TimeFilter) => void;
|
||||
leagueFilter: string;
|
||||
setLeagueFilter: (filter: string) => void;
|
||||
leagues: Array<{ id: string; name: string }>;
|
||||
onShowMoreFilters: () => void;
|
||||
}
|
||||
|
||||
export function RaceFilterBar({
|
||||
timeFilter,
|
||||
setTimeFilter,
|
||||
leagueFilter,
|
||||
setLeagueFilter,
|
||||
leagues,
|
||||
onShowMoreFilters,
|
||||
}: RaceFilterBarProps) {
|
||||
const leagueOptions = [
|
||||
{ value: 'all', label: 'All Leagues' },
|
||||
...leagues.map(l => ({ value: l.id, label: l.name }))
|
||||
];
|
||||
|
||||
const timeOptions = [
|
||||
{ id: 'upcoming', label: 'Upcoming' },
|
||||
{ id: 'live', label: 'Live', indicatorColor: 'bg-performance-green' },
|
||||
{ id: 'past', label: 'Past' },
|
||||
{ id: 'all', label: 'All' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card p={4}>
|
||||
<Stack direction="row" align="center" gap={4} wrap>
|
||||
<FilterGroup
|
||||
options={timeOptions}
|
||||
activeId={timeFilter}
|
||||
onSelect={(id) => setTimeFilter(id as TimeFilter)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={leagueFilter}
|
||||
onChange={(e) => setLeagueFilter(e.target.value)}
|
||||
options={leagueOptions}
|
||||
fullWidth={false}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onShowMoreFilters}
|
||||
>
|
||||
More Filters
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
102
apps/website/components/races/RaceList.tsx
Normal file
102
apps/website/components/races/RaceList.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
|
||||
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import type { RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { DateHeader } from '@/ui/DateHeader';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { RaceListItem } from '@/components/races/RaceListItem';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Calendar, CheckCircle2, Clock, PlayCircle, XCircle } from 'lucide-react';
|
||||
|
||||
interface RaceListProps {
|
||||
racesByDate: Array<{
|
||||
dateKey: string;
|
||||
dateLabel: string;
|
||||
races: RaceViewData[];
|
||||
}>;
|
||||
totalCount: number;
|
||||
onRaceClick: (raceId: string) => void;
|
||||
}
|
||||
|
||||
export function RaceList({ racesByDate, totalCount, onRaceClick }: RaceListProps) {
|
||||
const statusConfig = {
|
||||
scheduled: {
|
||||
icon: Clock,
|
||||
variant: 'primary' as const,
|
||||
label: 'Scheduled',
|
||||
},
|
||||
running: {
|
||||
icon: PlayCircle,
|
||||
variant: 'success' as const,
|
||||
label: 'LIVE',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
variant: 'default' as const,
|
||||
label: 'Completed',
|
||||
},
|
||||
cancelled: {
|
||||
icon: XCircle,
|
||||
variant: 'warning' as const,
|
||||
label: 'Cancelled',
|
||||
},
|
||||
};
|
||||
|
||||
if (racesByDate.length === 0) {
|
||||
return (
|
||||
<Card py={12} textAlign="center">
|
||||
<Stack align="center" gap={4}>
|
||||
<Box p={4} bg="bg-iron-gray" rounded="full">
|
||||
<Icon icon={Calendar} size={8} color="rgb(115, 115, 115)" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text weight="medium" color="text-white" block mb={1}>No races found</Text>
|
||||
<Text size="sm" color="text-gray-500">
|
||||
{totalCount === 0
|
||||
? 'No races have been scheduled yet'
|
||||
: 'Try adjusting your filters'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
{racesByDate.map((group) => (
|
||||
<Stack key={group.dateKey} gap={3}>
|
||||
<DateHeader
|
||||
label={group.dateLabel}
|
||||
count={group.races.length}
|
||||
/>
|
||||
|
||||
<Stack gap={2}>
|
||||
{group.races.map((race) => {
|
||||
const config = statusConfig[race.status as keyof typeof statusConfig] || statusConfig.scheduled;
|
||||
|
||||
return (
|
||||
<RaceListItem
|
||||
key={race.id}
|
||||
track={race.track}
|
||||
car={race.car}
|
||||
timeLabel={race.timeLabel}
|
||||
relativeTimeLabel={race.relativeTimeLabel}
|
||||
status={race.status}
|
||||
leagueName={race.leagueName ?? 'Unknown League'}
|
||||
leagueHref={routes.league.detail(race.leagueId ?? '')}
|
||||
strengthOfField={race.strengthOfField}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
statusConfig={config}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
143
apps/website/components/races/RaceListItem.tsx
Normal file
143
apps/website/components/races/RaceListItem.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
|
||||
|
||||
import { ArrowRight, Car, ChevronRight, LucideIcon, Trophy, Zap } from 'lucide-react';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface RaceListItemProps {
|
||||
track: string;
|
||||
car: string;
|
||||
timeLabel?: string;
|
||||
relativeTimeLabel?: string;
|
||||
dateLabel?: string;
|
||||
dayLabel?: string;
|
||||
status: string;
|
||||
leagueName?: string | null;
|
||||
leagueHref?: string;
|
||||
strengthOfField?: number | null;
|
||||
onClick: () => void;
|
||||
statusConfig: {
|
||||
icon: LucideIcon;
|
||||
variant: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
label: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function RaceListItem({
|
||||
track,
|
||||
car,
|
||||
timeLabel,
|
||||
relativeTimeLabel,
|
||||
dateLabel,
|
||||
dayLabel,
|
||||
status,
|
||||
leagueName,
|
||||
leagueHref,
|
||||
strengthOfField,
|
||||
onClick,
|
||||
statusConfig,
|
||||
}: RaceListItemProps) {
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
rounded="xl"
|
||||
bg="bg-iron-gray"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
p={4}
|
||||
cursor="pointer"
|
||||
transition
|
||||
hoverScale
|
||||
group
|
||||
>
|
||||
{/* Live indicator */}
|
||||
{status === 'running' && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
h="1"
|
||||
style={{ background: 'linear-gradient(to right, #10b981, rgba(16, 185, 129, 0.5), #10b981)' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
{/* Time/Date Column */}
|
||||
<Box flexShrink={0} textAlign="center" minWidth="60px">
|
||||
{dateLabel && (
|
||||
<Text size="xs" color="text-gray-500" block style={{ textTransform: 'uppercase' }}>
|
||||
{dateLabel}
|
||||
</Text>
|
||||
)}
|
||||
<Text size={dayLabel ? "2xl" : "lg"} weight="bold" color="text-white" block>
|
||||
{dayLabel || timeLabel}
|
||||
</Text>
|
||||
<Text size="xs" color={status === 'running' ? 'text-performance-green' : 'text-gray-400'} block>
|
||||
{status === 'running' ? 'LIVE' : relativeTimeLabel || timeLabel}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Divider */}
|
||||
<Box w="px" h="10" alignSelf="stretch" bg="bg-charcoal-outline" />
|
||||
|
||||
{/* Main Content */}
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Stack direction="row" align="start" justify="between" gap={4}>
|
||||
<Box minWidth="0">
|
||||
<Heading level={3} truncate>
|
||||
{track}
|
||||
</Heading>
|
||||
<Stack direction="row" align="center" gap={3} mt={1}>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={Car} size={3.5} color="rgb(156, 163, 175)" />
|
||||
<Text size="sm" color="text-gray-400">{car}</Text>
|
||||
</Stack>
|
||||
{strengthOfField && (
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={Zap} size={3.5} color="rgb(245, 158, 11)" />
|
||||
<Text size="sm" color="text-gray-400">SOF {strengthOfField}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Status Badge */}
|
||||
<Badge variant={statusConfig.variant}>
|
||||
<Icon icon={StatusIcon} size={3.5} />
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
{/* League Link */}
|
||||
{leagueName && leagueHref && (
|
||||
<Box mt={3} pt={3} borderTop borderColor="border-charcoal-outline" bgOpacity={0.5}>
|
||||
<Link
|
||||
href={leagueHref}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Icon icon={Trophy} size={3.5} mr={2} />
|
||||
{leagueName}
|
||||
<Icon icon={ArrowRight} size={3} ml={2} />
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Arrow */}
|
||||
<Icon icon={ChevronRight} size={5} color="rgb(115, 115, 115)" flexShrink={0} />
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
74
apps/website/components/races/RaceListItemWrapper.tsx
Normal file
74
apps/website/components/races/RaceListItemWrapper.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
|
||||
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { RaceListItem as UiRaceListItem } from '@/components/races/RaceListItem';
|
||||
import { CheckCircle2, Clock, PlayCircle, XCircle } from 'lucide-react';
|
||||
|
||||
interface Race {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
sessionType: string;
|
||||
leagueId?: string | null;
|
||||
leagueName?: string | null;
|
||||
strengthOfField?: number | null;
|
||||
}
|
||||
|
||||
interface RaceListItemProps {
|
||||
race: Race;
|
||||
onClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export function RaceListItem({ race, onClick }: RaceListItemProps) {
|
||||
const statusConfig = {
|
||||
scheduled: {
|
||||
icon: Clock,
|
||||
variant: 'primary' as const,
|
||||
label: 'Scheduled',
|
||||
},
|
||||
running: {
|
||||
icon: PlayCircle,
|
||||
variant: 'success' as const,
|
||||
label: 'LIVE',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
variant: 'default' as const,
|
||||
label: 'Completed',
|
||||
},
|
||||
cancelled: {
|
||||
icon: XCircle,
|
||||
variant: 'warning' as const,
|
||||
label: 'Cancelled',
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[race.status];
|
||||
|
||||
const formatTime = (date: string) => {
|
||||
return new Date(date).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const date = new Date(race.scheduledAt);
|
||||
|
||||
return (
|
||||
<UiRaceListItem
|
||||
track={race.track}
|
||||
car={race.car}
|
||||
dateLabel={date.toLocaleDateString('en-US', { month: 'short' })}
|
||||
dayLabel={date.getDate().toString()}
|
||||
timeLabel={formatTime(race.scheduledAt)}
|
||||
status={race.status}
|
||||
leagueName={race.leagueName}
|
||||
leagueHref={race.leagueId ? routes.league.detail(race.leagueId) : undefined}
|
||||
strengthOfField={race.strengthOfField}
|
||||
onClick={() => onClick(race.id)}
|
||||
statusConfig={config}
|
||||
/>
|
||||
);
|
||||
}
|
||||
39
apps/website/components/races/RaceResultCardWrapper.tsx
Normal file
39
apps/website/components/races/RaceResultCardWrapper.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
|
||||
|
||||
import { RaceResultViewModel } from '@/lib/view-models/RaceResultViewModel';
|
||||
import { RaceResultCard as UiRaceResultCard } from '@/ui/RaceResultCard';
|
||||
|
||||
interface RaceResultCardProps {
|
||||
race: {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
};
|
||||
result: RaceResultViewModel;
|
||||
league?: {
|
||||
name: string;
|
||||
};
|
||||
showLeague?: boolean;
|
||||
}
|
||||
|
||||
export function RaceResultCard({
|
||||
race,
|
||||
result,
|
||||
league,
|
||||
showLeague = true,
|
||||
}: RaceResultCardProps) {
|
||||
return (
|
||||
<UiRaceResultCard
|
||||
raceId={race.id}
|
||||
track={race.track}
|
||||
car={race.car}
|
||||
scheduledAt={race.scheduledAt}
|
||||
position={result.position}
|
||||
startPosition={result.startPosition}
|
||||
incidents={result.incidents}
|
||||
leagueName={league?.name}
|
||||
showLeague={showLeague}
|
||||
/>
|
||||
);
|
||||
}
|
||||
107
apps/website/components/races/RaceResultRow.tsx
Normal file
107
apps/website/components/races/RaceResultRow.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
|
||||
|
||||
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface ResultEntry {
|
||||
position: number;
|
||||
driverId: string;
|
||||
driverName: string;
|
||||
driverAvatar: string;
|
||||
country: string;
|
||||
car: string;
|
||||
laps: number;
|
||||
time: string;
|
||||
fastestLap: string;
|
||||
points: number;
|
||||
incidents: number;
|
||||
isCurrentUser: boolean;
|
||||
}
|
||||
|
||||
interface RaceResultRowProps {
|
||||
result: ResultEntry;
|
||||
points: number;
|
||||
}
|
||||
|
||||
export function RaceResultRow({ result, points }: RaceResultRowProps) {
|
||||
const { isCurrentUser, position, driverAvatar, driverName, country, car, laps, incidents, time, fastestLap } = result;
|
||||
|
||||
const getPositionColor = (pos: number) => {
|
||||
if (pos === 1) return { bg: 'bg-yellow-500/20', color: 'text-yellow-400' };
|
||||
if (pos === 2) return { bg: 'bg-gray-400/20', color: 'text-gray-300' };
|
||||
if (pos === 3) return { bg: 'bg-amber-600/20', color: 'text-amber-600' };
|
||||
return { bg: 'bg-iron-gray/50', color: 'text-gray-500' };
|
||||
};
|
||||
|
||||
const posConfig = getPositionColor(position);
|
||||
|
||||
return (
|
||||
<Surface
|
||||
variant={isCurrentUser ? 'muted' : 'dark'}
|
||||
rounded="xl"
|
||||
border={isCurrentUser}
|
||||
padding={3}
|
||||
className={isCurrentUser ? 'border-primary-blue/40' : ''}
|
||||
style={isCurrentUser ? { background: 'linear-gradient(to right, rgba(59, 130, 246, 0.2), rgba(59, 130, 246, 0.1), transparent)' } : {}}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
{/* Position */}
|
||||
<Box
|
||||
width="10"
|
||||
height="10"
|
||||
rounded="lg"
|
||||
display="flex"
|
||||
center
|
||||
className={`${posConfig.bg} ${posConfig.color}`}
|
||||
>
|
||||
<Text weight="bold">{position}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Avatar */}
|
||||
<Box position="relative" flexShrink={0}>
|
||||
<Box width="10" height="10" rounded="full" overflow="hidden" border={isCurrentUser} borderColor="border-primary-blue/50" className={isCurrentUser ? 'border-2' : ''}>
|
||||
<Image src={driverAvatar} alt={driverName} width={40} height={40} fullWidth fullHeight objectFit="cover" />
|
||||
</Box>
|
||||
<Box position="absolute" bottom="-0.5" right="-0.5" width="5" height="5" rounded="full" bg="bg-deep-graphite" display="flex" center style={{ fontSize: '0.625rem' }}>
|
||||
{CountryFlagDisplay.fromCountryCode(country).toString()}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Driver Info */}
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text weight="semibold" size="sm" color={isCurrentUser ? 'text-primary-blue' : 'text-white'} truncate>{driverName}</Text>
|
||||
{isCurrentUser && (
|
||||
<Box px={2} py={0.5} rounded="full" bg="bg-primary-blue">
|
||||
<Text size="xs" weight="bold" color="text-white">YOU</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={2} mt={1}>
|
||||
<Text size="xs" color="text-gray-500">{car}</Text>
|
||||
<Text size="xs" color="text-gray-500">•</Text>
|
||||
<Text size="xs" color="text-gray-500">Laps: {laps}</Text>
|
||||
<Text size="xs" color="text-gray-500">•</Text>
|
||||
<Text size="xs" color="text-gray-500">Incidents: {incidents}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Times */}
|
||||
<Box textAlign="right" style={{ minWidth: '100px' }}>
|
||||
<Text size="sm" font="mono" color="text-white" block>{time}</Text>
|
||||
<Text size="xs" color="text-performance-green" block mt={1}>FL: {fastestLap}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Points */}
|
||||
<Box p={2} rounded="lg" border={true} borderColor="border-warning-amber/20" bg="bg-warning-amber/10" textAlign="center" style={{ minWidth: '3.5rem' }}>
|
||||
<Text size="xs" color="text-gray-500" block>PTS</Text>
|
||||
<Text size="sm" weight="bold" color="text-warning-amber">{points}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
106
apps/website/components/races/RaceSidebar.tsx
Normal file
106
apps/website/components/races/RaceSidebar.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { Clock, Trophy, Users } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { SidebarRaceItem } from '@/ui/SidebarRaceItem';
|
||||
import { SidebarActionLink } from '@/ui/SidebarActionLink';
|
||||
import type { RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
interface RaceSidebarProps {
|
||||
upcomingRaces: RaceViewData[];
|
||||
recentResults: RaceViewData[];
|
||||
onRaceClick: (raceId: string) => void;
|
||||
}
|
||||
|
||||
export function RaceSidebar({ upcomingRaces, recentResults, onRaceClick }: RaceSidebarProps) {
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
{/* Upcoming This Week */}
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading level={3} icon={<Icon icon={Clock} size={4} color="rgb(59, 130, 246)" />}>
|
||||
Next Up
|
||||
</Heading>
|
||||
<Text size="xs" color="text-gray-500">This week</Text>
|
||||
</Stack>
|
||||
|
||||
{upcomingRaces.length === 0 ? (
|
||||
<Box py={4} textAlign="center">
|
||||
<Text size="sm" color="text-gray-400">No races scheduled this week</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack gap={3}>
|
||||
{upcomingRaces.map((race) => (
|
||||
<SidebarRaceItem
|
||||
key={race.id}
|
||||
race={{
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
scheduledAt: race.scheduledAt
|
||||
}}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Recent Results */}
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Heading level={3} icon={<Icon icon={Trophy} size={4} color="rgb(245, 158, 11)" />}>
|
||||
Recent Results
|
||||
</Heading>
|
||||
|
||||
{recentResults.length === 0 ? (
|
||||
<Box py={4} textAlign="center">
|
||||
<Text size="sm" color="text-gray-400">No completed races yet</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack gap={3}>
|
||||
{recentResults.map((race) => (
|
||||
<SidebarRaceItem
|
||||
key={race.id}
|
||||
race={{
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
scheduledAt: race.scheduledAt
|
||||
}}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Heading level={3}>Quick Actions</Heading>
|
||||
<Stack gap={2}>
|
||||
<SidebarActionLink
|
||||
href={routes.public.leagues}
|
||||
icon={Users}
|
||||
label="Browse Leagues"
|
||||
/>
|
||||
<SidebarActionLink
|
||||
href={routes.public.leaderboards}
|
||||
icon={Trophy}
|
||||
label="View Leaderboards"
|
||||
iconColor="text-warning-amber"
|
||||
iconBgColor="bg-warning-amber/10"
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
62
apps/website/components/races/UpcomingRaces.tsx
Normal file
62
apps/website/components/races/UpcomingRaces.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { MinimalEmptyState } from '@/components/shared/state/EmptyState';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { UpcomingRaceItem } from '@/ui/UpcomingRaceItem';
|
||||
import { UpcomingRacesList } from '@/components/races/UpcomingRacesList';
|
||||
import { Calendar } from 'lucide-react';
|
||||
|
||||
interface UpcomingRace {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
formattedDate: string;
|
||||
formattedTime: string;
|
||||
isMyLeague: boolean;
|
||||
}
|
||||
|
||||
interface UpcomingRacesProps {
|
||||
races: UpcomingRace[];
|
||||
hasRaces: boolean;
|
||||
}
|
||||
|
||||
export function UpcomingRaces({ races, hasRaces }: UpcomingRacesProps) {
|
||||
return (
|
||||
<Card>
|
||||
<Stack direction="row" align="center" justify="between" mb={4}>
|
||||
<Heading level={3} icon={<Icon icon={Calendar} size={5} color="var(--primary-blue)" />}>
|
||||
Upcoming Races
|
||||
</Heading>
|
||||
<Link href={routes.public.races} variant="primary">
|
||||
<Text size="xs">View all</Text>
|
||||
</Link>
|
||||
</Stack>
|
||||
{hasRaces ? (
|
||||
<UpcomingRacesList>
|
||||
{races.slice(0, 5).map((race) => (
|
||||
<UpcomingRaceItem
|
||||
key={race.id}
|
||||
track={race.track}
|
||||
car={race.car}
|
||||
formattedDate={race.formattedDate}
|
||||
formattedTime={race.formattedTime}
|
||||
isMyLeague={race.isMyLeague}
|
||||
/>
|
||||
))}
|
||||
</UpcomingRacesList>
|
||||
) : (
|
||||
<MinimalEmptyState
|
||||
icon={Calendar}
|
||||
title="No upcoming races"
|
||||
description="Check back later for new events"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
14
apps/website/components/races/UpcomingRacesList.tsx
Normal file
14
apps/website/components/races/UpcomingRacesList.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
|
||||
interface UpcomingRacesListProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function UpcomingRacesList({ children }: UpcomingRacesListProps) {
|
||||
return (
|
||||
<Stack gap={3}>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
53
apps/website/components/races/UpcomingRacesSidebar.tsx
Normal file
53
apps/website/components/races/UpcomingRacesSidebar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { RaceSummaryItem } from '@/ui/RaceSummaryItem';
|
||||
|
||||
type UpcomingRace = {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string | Date;
|
||||
};
|
||||
|
||||
interface UpcomingRacesSidebarProps {
|
||||
races: UpcomingRace[];
|
||||
}
|
||||
|
||||
export function UpcomingRacesSidebar({ races }: UpcomingRacesSidebarProps) {
|
||||
if (!races.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card bg="bg-iron-gray/80" p={4}>
|
||||
<Stack direction="row" align="baseline" justify="between" mb={3}>
|
||||
<Heading level={3}>Upcoming races</Heading>
|
||||
<Button
|
||||
as="a"
|
||||
href="/races"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
View all
|
||||
</Button>
|
||||
</Stack>
|
||||
<Stack gap={3}>
|
||||
{races.slice(0, 4).map((race) => {
|
||||
const scheduledAt = typeof race.scheduledAt === 'string' ? new Date(race.scheduledAt) : race.scheduledAt;
|
||||
|
||||
return (
|
||||
<RaceSummaryItem
|
||||
key={race.id}
|
||||
track={race.track}
|
||||
meta={race.car}
|
||||
date={scheduledAt}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user