website refactor
This commit is contained in:
@@ -1,72 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||
import { RacesAllTemplate } from '@/templates/RacesAllTemplate';
|
||||
import { RacesIndexTemplate } from '@/templates/RacesIndexTemplate';
|
||||
import { useAllRacesPageData } from '@/hooks/race/useAllRacesPageData';
|
||||
import { type RacesViewData, type RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { Flag } from 'lucide-react';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import type { RacesViewData, RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||
import { Flag } from 'lucide-react';
|
||||
|
||||
export function RacesAllPageClient({ viewData: initialViewData }: ClientWrapperProps<RacesViewData>) {
|
||||
const router = useRouter();
|
||||
|
||||
// Client-side state for filters and pagination
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<'scheduled' | 'running' | 'completed' | 'cancelled' | 'all'>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [leagueFilter, setLeagueFilter] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [timeFilter, setTimeFilter] = useState<string>('upcoming');
|
||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||
|
||||
// Use React Query hook
|
||||
const { data: pageData, isLoading, error, refetch } = useAllRacesPageData(initialViewData);
|
||||
|
||||
// Transform data
|
||||
const races: RaceViewData[] = pageData?.races ?? [];
|
||||
const filteredRaces = useMemo(() => {
|
||||
const now = new Date();
|
||||
const races: RaceViewData[] = pageData?.races ?? [];
|
||||
return races.filter((race) => {
|
||||
if (statusFilter !== 'all' && race.status.toLowerCase() !== statusFilter.toLowerCase()) return false;
|
||||
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) return false;
|
||||
|
||||
const scheduledAt = new Date(race.scheduledAt);
|
||||
const isActuallyUpcoming = scheduledAt > now && race.status.toLowerCase() === 'scheduled';
|
||||
const isActuallyLive = race.status.toLowerCase() === 'running';
|
||||
const isActuallyPast = scheduledAt < now || race.status.toLowerCase() === 'completed' || race.status.toLowerCase() === 'cancelled';
|
||||
|
||||
// Filter and paginate (Note: This should be done by API per contract)
|
||||
const filteredRaces = races.filter((race: RaceViewData) => {
|
||||
if (statusFilter !== 'all' && race.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
if (timeFilter === 'upcoming' && !isActuallyUpcoming) return false;
|
||||
if (timeFilter === 'live' && !isActuallyLive) return false;
|
||||
if (timeFilter === 'past' && !isActuallyPast) return false;
|
||||
return true;
|
||||
});
|
||||
}, [pageData?.races, statusFilter, leagueFilter, timeFilter]);
|
||||
|
||||
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
|
||||
return false;
|
||||
}
|
||||
const nextUpRace = useMemo(() => {
|
||||
const now = new Date();
|
||||
return filteredRaces.find(r => new Date(r.scheduledAt) > now && r.status.toLowerCase() === 'scheduled');
|
||||
}, [filteredRaces]);
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const matchesTrack = race.track.toLowerCase().includes(query);
|
||||
const matchesCar = race.car.toLowerCase().includes(query);
|
||||
const matchesLeague = race.leagueName?.toLowerCase().includes(query);
|
||||
if (!matchesTrack && !matchesCar && !matchesLeague) {
|
||||
return false;
|
||||
const racesByDate = useMemo(() => {
|
||||
const grouped = new Map<string, typeof filteredRaces[0][]>();
|
||||
|
||||
filteredRaces.forEach((race) => {
|
||||
const dateKey = race.scheduledAt.split('T')[0]!;
|
||||
if (!grouped.has(dateKey)) {
|
||||
grouped.set(dateKey, []);
|
||||
}
|
||||
}
|
||||
grouped.get(dateKey)!.push(race);
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filteredRaces.length / ITEMS_PER_PAGE);
|
||||
const paginatedRaces = filteredRaces.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
||||
|
||||
// Actions
|
||||
const handleRaceClick = (raceId: string) => {
|
||||
router.push(routes.race.detail(raceId));
|
||||
};
|
||||
|
||||
const handleLeagueClick = (leagueId: string) => {
|
||||
router.push(routes.league.detail(leagueId));
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
return Array.from(grouped.entries())
|
||||
.sort(([a], [b]) => timeFilter === 'past' ? b.localeCompare(a) : a.localeCompare(b))
|
||||
.map(([dateKey, dayRaces]) => ({
|
||||
dateKey,
|
||||
dateLabel: dayRaces[0]?.scheduledAtLabel || '',
|
||||
races: dayRaces,
|
||||
}));
|
||||
}, [filteredRaces, timeFilter]);
|
||||
|
||||
return (
|
||||
<StatefulPageWrapper
|
||||
@@ -75,27 +70,22 @@ export function RacesAllPageClient({ viewData: initialViewData }: ClientWrapperP
|
||||
error={error as Error | null}
|
||||
retry={refetch}
|
||||
Template={() => pageData ? (
|
||||
<RacesAllTemplate
|
||||
viewData={pageData}
|
||||
races={paginatedRaces}
|
||||
totalFilteredCount={filteredRaces.length}
|
||||
isLoading={false}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
onPageChange={handlePageChange}
|
||||
<RacesIndexTemplate
|
||||
viewData={{
|
||||
...pageData,
|
||||
races: filteredRaces,
|
||||
racesByDate,
|
||||
nextUpRace,
|
||||
}}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
leagueFilter={leagueFilter}
|
||||
setLeagueFilter={setLeagueFilter}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
showFilters={showFilters}
|
||||
setShowFilters={setShowFilters}
|
||||
timeFilter={timeFilter}
|
||||
setTimeFilter={setTimeFilter}
|
||||
showFilterModal={showFilterModal}
|
||||
setShowFilterModal={setShowFilterModal}
|
||||
onRaceClick={handleRaceClick}
|
||||
onLeagueClick={handleLeagueClick}
|
||||
onRaceClick={(id) => router.push(`/races/${id}`)}
|
||||
/>
|
||||
) : null}
|
||||
loading={{ variant: 'skeleton', message: 'Loading races...' }}
|
||||
|
||||
@@ -2,30 +2,54 @@
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { RacesTemplate, type TimeFilter, type RaceStatusFilter } from '@/templates/RacesTemplate';
|
||||
import { RacesIndexTemplate } from '@/templates/RacesIndexTemplate';
|
||||
import type { RacesViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
export function RacesPageClient({ viewData }: ClientWrapperProps<RacesViewData>) {
|
||||
const router = useRouter();
|
||||
const [statusFilter, setStatusFilter] = useState<RaceStatusFilter>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [leagueFilter, setLeagueFilter] = useState<string>('all');
|
||||
const [timeFilter, setTimeFilter] = useState<TimeFilter>('upcoming');
|
||||
const [timeFilter, setTimeFilter] = useState<string>('upcoming');
|
||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||
|
||||
const filteredRaces = useMemo(() => {
|
||||
const now = new Date();
|
||||
return viewData.races.filter((race) => {
|
||||
if (statusFilter !== 'all' && race.status !== statusFilter) return false;
|
||||
// Status filter: case-insensitive match
|
||||
if (statusFilter !== 'all' && race.status.toLowerCase() !== statusFilter.toLowerCase()) return false;
|
||||
|
||||
// League filter
|
||||
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) return false;
|
||||
if (timeFilter === 'upcoming' && !race.isUpcoming) return false;
|
||||
if (timeFilter === 'live' && !race.isLive) return false;
|
||||
if (timeFilter === 'past' && !race.isPast) return false;
|
||||
|
||||
// Time filter: ensure we are checking the correct flags
|
||||
// Note: we also check the actual date to be safe against stale API flags
|
||||
const scheduledAt = new Date(race.scheduledAt);
|
||||
const isActuallyUpcoming = scheduledAt > now && race.status.toLowerCase() === 'scheduled';
|
||||
const isActuallyLive = race.status.toLowerCase() === 'running';
|
||||
const isActuallyPast = scheduledAt < now || race.status.toLowerCase() === 'completed' || race.status.toLowerCase() === 'cancelled';
|
||||
|
||||
if (timeFilter === 'upcoming' && !isActuallyUpcoming) return false;
|
||||
if (timeFilter === 'live' && !isActuallyLive) return false;
|
||||
if (timeFilter === 'past' && !isActuallyPast) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [viewData.races, statusFilter, leagueFilter, timeFilter]);
|
||||
|
||||
const nextUpRace = useMemo(() => {
|
||||
const now = new Date();
|
||||
// Find the first upcoming race in the filtered list
|
||||
return filteredRaces.find(r => new Date(r.scheduledAt) > now && r.status.toLowerCase() === 'scheduled');
|
||||
}, [filteredRaces]);
|
||||
|
||||
const racesByDate = useMemo(() => {
|
||||
const grouped = new Map<string, typeof filteredRaces[0][]>();
|
||||
|
||||
// If we have a "Next Up" race and we are in upcoming view, we might want to exclude it from the list
|
||||
// to avoid duplication, but usually it's better to keep the list complete.
|
||||
// For this redesign, we keep the list complete for scanning.
|
||||
|
||||
filteredRaces.forEach((race) => {
|
||||
const dateKey = race.scheduledAt.split('T')[0]!;
|
||||
if (!grouped.has(dateKey)) {
|
||||
@@ -33,19 +57,23 @@ export function RacesPageClient({ viewData }: ClientWrapperProps<RacesViewData>)
|
||||
}
|
||||
grouped.get(dateKey)!.push(race);
|
||||
});
|
||||
return Array.from(grouped.entries()).map(([dateKey, dayRaces]) => ({
|
||||
dateKey,
|
||||
dateLabel: dayRaces[0]?.scheduledAtLabel || '',
|
||||
races: dayRaces,
|
||||
}));
|
||||
}, [filteredRaces]);
|
||||
|
||||
return Array.from(grouped.entries())
|
||||
.sort(([a], [b]) => timeFilter === 'past' ? b.localeCompare(a) : a.localeCompare(b))
|
||||
.map(([dateKey, dayRaces]) => ({
|
||||
dateKey,
|
||||
dateLabel: dayRaces[0]?.scheduledAtLabel || '',
|
||||
races: dayRaces,
|
||||
}));
|
||||
}, [filteredRaces, timeFilter]);
|
||||
|
||||
return (
|
||||
<RacesTemplate
|
||||
<RacesIndexTemplate
|
||||
viewData={{
|
||||
...viewData,
|
||||
races: filteredRaces,
|
||||
racesByDate,
|
||||
nextUpRace,
|
||||
}}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
@@ -56,9 +84,6 @@ export function RacesPageClient({ viewData }: ClientWrapperProps<RacesViewData>)
|
||||
showFilterModal={showFilterModal}
|
||||
setShowFilterModal={setShowFilterModal}
|
||||
onRaceClick={(id) => router.push(`/races/${id}`)}
|
||||
onLeagueClick={(id) => router.push(`/leagues/${id}`)}
|
||||
onWithdraw={(id) => console.log('Withdraw', id)}
|
||||
onCancel={(id) => console.log('Cancel', id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { ChevronRight, PlayCircle } from 'lucide-react';
|
||||
|
||||
interface LiveRaceItemProps {
|
||||
@@ -14,30 +15,21 @@ interface LiveRaceItemProps {
|
||||
|
||||
export function LiveRaceItem({ track, leagueName, onClick }: LiveRaceItemProps) {
|
||||
return (
|
||||
<Box
|
||||
<Panel
|
||||
variant="precision"
|
||||
padding="sm"
|
||||
onClick={onClick}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
p={4}
|
||||
bg="bg-deep-graphite/80"
|
||||
rounded="lg"
|
||||
border
|
||||
borderColor="border-performance-green/20"
|
||||
cursor="pointer"
|
||||
hoverBorderColor="performance-green/40"
|
||||
transition
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
<Box p={2} bg="bg-performance-green/20" rounded="lg">
|
||||
<Icon icon={PlayCircle} size={5} color="rgb(16, 185, 129)" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={3}>{track}</Heading>
|
||||
<Text size="sm" color="text-gray-400">{leagueName}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Icon icon={ChevronRight} size={5} color="rgb(156, 163, 175)" />
|
||||
</Box>
|
||||
<Stack direction="row" align="center" justify="between" fullWidth>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Icon icon={PlayCircle} size={5} intent="success" animate="pulse" />
|
||||
<Stack gap={0.5}>
|
||||
<Heading level={5} weight="bold">{track}</Heading>
|
||||
<Text size="xs" variant="low" uppercase letterSpacing="widest">{leagueName}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Icon icon={ChevronRight} size={4} intent="low" />
|
||||
</Stack>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { LiveRaceItem } from '@/components/races/LiveRaceItem';
|
||||
import type { RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Zap } from 'lucide-react';
|
||||
|
||||
interface LiveRacesBannerProps {
|
||||
liveRaces: RaceViewData[];
|
||||
@@ -14,45 +17,26 @@ export function LiveRacesBanner({ liveRaces, onRaceClick }: LiveRacesBannerProps
|
||||
if (liveRaces.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
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)"
|
||||
>
|
||||
<Stack
|
||||
position="absolute"
|
||||
top="0"
|
||||
right="0"
|
||||
w="32"
|
||||
h="32"
|
||||
bg="bg-performance-green/20"
|
||||
rounded="full"
|
||||
blur="xl"
|
||||
/>
|
||||
|
||||
<Stack position="relative" zIndex={10}>
|
||||
<Stack mb={4}>
|
||||
<Stack direction="row" align="center" gap={2} bg="bg-performance-green/20" px={3} py={1} rounded="full" w="fit">
|
||||
<Stack w="2" h="2" bg="bg-performance-green" rounded="full" />
|
||||
<Text weight="semibold" size="sm" color="text-performance-green">LIVE NOW</Text>
|
||||
</Stack>
|
||||
<Panel variant="glass" padding="md">
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Zap} size={4} intent="success" animate="pulse" />
|
||||
<Text weight="bold" size="sm" variant="success" uppercase letterSpacing="widest">
|
||||
Live Sessions
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={3}>
|
||||
<Stack gap={2}>
|
||||
{liveRaces.map((race) => (
|
||||
<LiveRaceItem
|
||||
key={race.id}
|
||||
track={race.track}
|
||||
leagueName={race.leagueName ?? 'Unknown League'}
|
||||
leagueName={race.leagueName ?? 'Official'}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
77
apps/website/components/races/NextUpRacePanel.tsx
Normal file
77
apps/website/components/races/NextUpRacePanel.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Clock, MapPin, Car, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/ui/Button';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface NextUpRacePanelProps {
|
||||
race: any;
|
||||
onRaceClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export function NextUpRacePanel({ race, onRaceClick }: NextUpRacePanelProps) {
|
||||
if (!race) return null;
|
||||
|
||||
return (
|
||||
<Stack gap={3}>
|
||||
<Text size="xs" variant="low" weight="bold" uppercase letterSpacing="widest" paddingX={1}>
|
||||
Next Up
|
||||
</Text>
|
||||
|
||||
<Surface
|
||||
as={Link}
|
||||
href={`/races/${race.id}`}
|
||||
variant="precision"
|
||||
padding="lg"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onRaceClick(race.id);
|
||||
}}
|
||||
cursor="pointer"
|
||||
hoverBg="rgba(255, 255, 255, 0.02)"
|
||||
display="block"
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<Stack direction={{ base: 'col', md: 'row' }} justify="between" align={{ base: 'start', md: 'center' }} gap={6}>
|
||||
<Stack gap={4} flex={1}>
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" variant="primary" weight="bold" uppercase letterSpacing="widest">
|
||||
{race.leagueName}
|
||||
</Text>
|
||||
<Text size="2xl" weight="bold">
|
||||
{race.track}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" gap={6} wrap>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Clock} size={4} intent="low" />
|
||||
<Text size="sm" weight="medium">{race.timeLabel}</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Car} size={4} intent="low" />
|
||||
<Text size="sm" weight="medium">{race.car}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRaceClick(race.id);
|
||||
}}
|
||||
icon={<Icon icon={ChevronRight} size={4} />}
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
</Stack>
|
||||
</Surface>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import type { TimeFilter } from '@/templates/RacesTemplate';
|
||||
import { Button } from '@/ui/Button';
|
||||
@@ -6,6 +6,8 @@ import { Card } from '@/ui/Card';
|
||||
import { FilterGroup } from '@/ui/FilterGroup';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { SlidersHorizontal } from 'lucide-react';
|
||||
|
||||
interface RaceFilterBarProps {
|
||||
timeFilter: TimeFilter;
|
||||
@@ -31,32 +33,36 @@ export function RaceFilterBar({
|
||||
|
||||
const timeOptions = [
|
||||
{ id: 'upcoming', label: 'Upcoming' },
|
||||
{ id: 'live', label: 'Live', indicatorColor: 'bg-performance-green' },
|
||||
{ id: 'live', label: 'Live' },
|
||||
{ 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)}
|
||||
/>
|
||||
<Card variant="precision" padding="sm">
|
||||
<Stack direction="row" align="center" justify="between" wrap gap={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}
|
||||
/>
|
||||
<Select
|
||||
value={leagueFilter}
|
||||
onChange={(e) => setLeagueFilter(e.target.value)}
|
||||
options={leagueOptions}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onShowMoreFilters}
|
||||
icon={<Icon icon={SlidersHorizontal} size={3} />}
|
||||
>
|
||||
More Filters
|
||||
Filters
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Modal } from '@/components/shared/Modal';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { StatusDot } from '@/ui/StatusDot';
|
||||
import { Filter, Search } from 'lucide-react';
|
||||
|
||||
export type TimeFilter = 'all' | 'upcoming' | 'live' | 'past';
|
||||
@@ -48,9 +49,9 @@ export function RaceFilterModal({
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title="Filters"
|
||||
icon={<Icon icon={Filter} size={5} color="text-primary-accent" />}
|
||||
icon={<Icon icon={Filter} size={5} intent="primary" />}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Stack gap={6}>
|
||||
{/* Search */}
|
||||
{showSearch && (
|
||||
<Input
|
||||
@@ -59,15 +60,15 @@ export function RaceFilterModal({
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Track, car, or league..."
|
||||
icon={<Icon icon={Search} size={4} color="text-gray-500" />}
|
||||
icon={<Icon icon={Search} size={4} intent="low" />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Time Filter */}
|
||||
{showTimeFilter && (
|
||||
<Stack>
|
||||
<Text as="label" size="sm" color="text-gray-400" block mb={2}>Time</Text>
|
||||
<Stack display="flex" flexWrap="wrap" gap={2}>
|
||||
<Stack gap={2}>
|
||||
<Text as="label" size="xs" variant="low" weight="bold" uppercase letterSpacing="widest">Time</Text>
|
||||
<Stack direction="row" wrap gap={2}>
|
||||
{(['upcoming', 'live', 'past', 'all'] as TimeFilter[]).map(filter => (
|
||||
<Button
|
||||
key={filter}
|
||||
@@ -75,7 +76,11 @@ export function RaceFilterModal({
|
||||
size="sm"
|
||||
onClick={() => setTimeFilter(filter)}
|
||||
>
|
||||
{filter === 'live' && <Stack as="span" width="2" height="2" bg="bg-success-green" rounded="full" mr={1.5} animate="pulse" />}
|
||||
{filter === 'live' && (
|
||||
<Stack mr={2}>
|
||||
<StatusDot intent="success" size={1.5} pulse />
|
||||
</Stack>
|
||||
)}
|
||||
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
@@ -111,7 +116,7 @@ export function RaceFilterModal({
|
||||
{/* Clear Filters */}
|
||||
{(statusFilter !== 'all' || leagueFilter !== 'all' || searchQuery || (showTimeFilter && timeFilter !== 'upcoming')) && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setStatusFilter('all');
|
||||
setLeagueFilter('all');
|
||||
|
||||
88
apps/website/components/races/RaceListRow.tsx
Normal file
88
apps/website/components/races/RaceListRow.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { RaceRow } from '@/ui/RaceRow';
|
||||
import { RaceRowCell } from '@/ui/RaceRowCell';
|
||||
import { StatusBadge } from '@/ui/StatusBadge';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Clock, MapPin, Car as CarIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface RaceListRowProps {
|
||||
race: {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
leagueName: string;
|
||||
timeLabel: string;
|
||||
status: string;
|
||||
isLive: boolean;
|
||||
isPast: boolean;
|
||||
};
|
||||
onClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export function RaceListRow({ race, onClick }: RaceListRowProps) {
|
||||
const status = race.isLive ? 'live' : (race.isPast ? 'past' : 'upcoming');
|
||||
const emphasis = race.isPast ? 'low' : 'medium';
|
||||
|
||||
return (
|
||||
<RaceRow
|
||||
as={Link}
|
||||
href={`/races/${race.id}`}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onClick(race.id);
|
||||
}}
|
||||
status={status}
|
||||
emphasis={emphasis}
|
||||
>
|
||||
<RaceRowCell width="5rem" align="center">
|
||||
<Stack gap={0.5} align="center">
|
||||
<Text size="sm" weight="bold" variant={race.isLive ? 'success' : 'high'}>
|
||||
{race.timeLabel}
|
||||
</Text>
|
||||
{race.isLive && (
|
||||
<Text size="xs" variant="success" weight="bold" uppercase>Live</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</RaceRowCell>
|
||||
|
||||
<RaceRowCell label="Track">
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={MapPin} size={3} intent="low" />
|
||||
<Text size="sm" weight="medium" truncate>{race.track}</Text>
|
||||
</Stack>
|
||||
</RaceRowCell>
|
||||
|
||||
<RaceRowCell label="League" hideOnMobile>
|
||||
<Text size="sm" variant="low" truncate>{race.leagueName}</Text>
|
||||
</RaceRowCell>
|
||||
|
||||
<RaceRowCell label="Car" hideOnMobile>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={CarIcon} size={3} intent="low" />
|
||||
<Text size="sm" variant="low" truncate>{race.car}</Text>
|
||||
</Stack>
|
||||
</RaceRowCell>
|
||||
|
||||
<RaceRowCell width="6rem" align="right">
|
||||
<StatusBadge variant={getStatusVariant(race.status)}>
|
||||
{race.status}
|
||||
</StatusBadge>
|
||||
</RaceRowCell>
|
||||
</RaceRow>
|
||||
);
|
||||
}
|
||||
|
||||
function getStatusVariant(status: string): any {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'running': return 'success';
|
||||
case 'completed': return 'neutral';
|
||||
case 'cancelled': return 'error';
|
||||
case 'scheduled': return 'info';
|
||||
default: return 'neutral';
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { CalendarDays, Clock, Flag, LucideIcon, Trophy, Zap } from 'lucide-react';
|
||||
|
||||
@@ -22,57 +22,50 @@ export function RacePageHeader({
|
||||
completedCount,
|
||||
}: RacePageHeaderProps) {
|
||||
return (
|
||||
<Surface
|
||||
bg="bg-surface-charcoal"
|
||||
rounded="xl"
|
||||
border
|
||||
borderColor="border-outline-steel"
|
||||
padding={6}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
<Panel
|
||||
variant="precision"
|
||||
padding="lg"
|
||||
>
|
||||
{/* Background Accent */}
|
||||
<Stack
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
height="1"
|
||||
bg="bg-primary-accent"
|
||||
/>
|
||||
|
||||
<Stack gap={6}>
|
||||
<Stack gap={8}>
|
||||
<Stack gap={2}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Icon icon={Flag} size={6} color="var(--primary-accent)" />
|
||||
<Heading level={1}>RACE DASHBOARD</Heading>
|
||||
<Icon icon={Flag} size={6} intent="primary" />
|
||||
<Heading level={1} uppercase weight="bold">Race Dashboard</Heading>
|
||||
</Stack>
|
||||
<Text color="text-gray-400" size="sm">
|
||||
<Text variant="low" size="sm">
|
||||
Precision tracking for upcoming sessions and live events.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Grid cols={2} mdCols={4} gap={4}>
|
||||
<StatItem icon={CalendarDays} label="TOTAL SESSIONS" value={totalCount} />
|
||||
<StatItem icon={Clock} label="SCHEDULED" value={scheduledCount} color="text-primary-accent" />
|
||||
<StatItem icon={Zap} label="LIVE NOW" value={runningCount} color="text-success-green" />
|
||||
<StatItem icon={Trophy} label="COMPLETED" value={completedCount} color="text-gray-400" />
|
||||
<Grid cols={2} mdCols={4} gap={6}>
|
||||
<StatItem icon={CalendarDays} label="Total Sessions" value={totalCount} />
|
||||
<StatItem icon={Clock} label="Scheduled" value={scheduledCount} variant="primary" />
|
||||
<StatItem icon={Zap} label="Live Now" value={runningCount} variant="success" />
|
||||
<StatItem icon={Trophy} label="Completed" value={completedCount} variant="low" />
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Surface>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({ icon, label, value, color = 'text-white' }: { icon: LucideIcon, label: string, value: number, color?: string }) {
|
||||
function StatItem({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
variant = 'high'
|
||||
}: {
|
||||
icon: LucideIcon,
|
||||
label: string,
|
||||
value: number,
|
||||
variant?: 'high' | 'low' | 'primary' | 'success' | 'warning' | 'critical'
|
||||
}) {
|
||||
return (
|
||||
<Stack p={4} bg="bg-base-black" bgOpacity={0.5} border borderColor="border-outline-steel">
|
||||
<Stack gap={1}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={icon} size={3} color={color === 'text-white' ? '#9ca3af' : undefined} groupHoverTextColor={color !== 'text-white' ? color : undefined} />
|
||||
<Text size="xs" color="text-gray-500" weight="bold" uppercase>{label}</Text>
|
||||
</Stack>
|
||||
<Text size="2xl" weight="bold" color={color}>{value}</Text>
|
||||
<Stack gap={2}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={icon} size={3} intent={variant === 'high' ? 'low' : variant} />
|
||||
<Text size="xs" variant="low" weight="bold" uppercase letterSpacing="widest">{label}</Text>
|
||||
</Stack>
|
||||
<Text size="3xl" weight="bold" variant={variant} mono>{value}</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from 'react';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
|
||||
import { SessionStatusBadge, type SessionStatus } from './SessionStatusBadge';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
|
||||
interface RaceRow {
|
||||
id: string;
|
||||
@@ -25,8 +26,7 @@ export function RaceScheduleTable({ races, onRaceClick }: RaceScheduleTableProps
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Time</TableHeader>
|
||||
<TableHeader>Track</TableHeader>
|
||||
<TableHeader>Car</TableHeader>
|
||||
<TableHeader>Session Details</TableHeader>
|
||||
<TableHeader>League</TableHeader>
|
||||
<TableHeader textAlign="right">Status</TableHeader>
|
||||
</TableRow>
|
||||
@@ -39,21 +39,23 @@ export function RaceScheduleTable({ races, onRaceClick }: RaceScheduleTableProps
|
||||
clickable
|
||||
>
|
||||
<TableCell>
|
||||
<Text size="xs" variant="telemetry" weight="bold">{race.time}</Text>
|
||||
<Text size="xs" variant="telemetry" weight="bold" mono>{race.time}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text size="sm" weight="bold" variant="high">
|
||||
{race.track}
|
||||
</Text>
|
||||
<Stack gap={0.5}>
|
||||
<Text size="sm" weight="bold" variant="high">
|
||||
{race.track}
|
||||
</Text>
|
||||
<Text size="xs" variant="low" uppercase letterSpacing="widest">{race.car}</Text>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text size="xs" variant="low">{race.car}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text size="xs" variant="low">{race.leagueName || 'Official'}</Text>
|
||||
<Text size="xs" variant="low" weight="medium">{race.leagueName || 'Official'}</Text>
|
||||
</TableCell>
|
||||
<TableCell textAlign="right">
|
||||
<SessionStatusBadge status={race.status} />
|
||||
<Stack direction="row" justify="end">
|
||||
<SessionStatusBadge status={race.status} />
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -4,12 +4,11 @@ import { SidebarRaceItem } from '@/components/races/SidebarRaceItem';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import type { RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { SidebarActionLink } from '@/ui/SidebarActionLink';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Clock, Trophy, Users } from 'lucide-react';
|
||||
import { Trophy, Users } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface RaceSidebarProps {
|
||||
@@ -23,15 +22,16 @@ export function RaceSidebar({ upcomingRaces, recentResults, onRaceClick }: RaceS
|
||||
<Stack gap={6}>
|
||||
{/* Upcoming This Week */}
|
||||
<Panel
|
||||
variant="precision"
|
||||
title="Next Up"
|
||||
description="This week"
|
||||
description="Scheduled sessions"
|
||||
>
|
||||
{upcomingRaces.length === 0 ? (
|
||||
<Box paddingY={4} textAlign="center">
|
||||
<Text size="sm" variant="low">No races scheduled this week</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack gap={3}>
|
||||
<Stack gap={1}>
|
||||
{upcomingRaces.map((race) => (
|
||||
<SidebarRaceItem
|
||||
key={race.id}
|
||||
@@ -49,14 +49,16 @@ export function RaceSidebar({ upcomingRaces, recentResults, onRaceClick }: RaceS
|
||||
|
||||
{/* Recent Results */}
|
||||
<Panel
|
||||
variant="precision"
|
||||
title="Recent Results"
|
||||
description="Latest finishes"
|
||||
>
|
||||
{recentResults.length === 0 ? (
|
||||
<Box paddingY={4} textAlign="center">
|
||||
<Text size="sm" variant="low">No completed races yet</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack gap={3}>
|
||||
<Stack gap={1}>
|
||||
{recentResults.map((race) => (
|
||||
<SidebarRaceItem
|
||||
key={race.id}
|
||||
@@ -73,7 +75,7 @@ export function RaceSidebar({ upcomingRaces, recentResults, onRaceClick }: RaceS
|
||||
</Panel>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Panel title="Quick Actions">
|
||||
<Panel variant="precision" title="Quick Actions">
|
||||
<Stack gap={2}>
|
||||
<SidebarActionLink
|
||||
href={routes.public.leagues}
|
||||
|
||||
69
apps/website/components/races/RacesCommandBar.tsx
Normal file
69
apps/website/components/races/RacesCommandBar.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Search, SlidersHorizontal } from 'lucide-react';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { SegmentedControl } from '@/ui/SegmentedControl';
|
||||
|
||||
interface RacesCommandBarProps {
|
||||
timeFilter: string;
|
||||
setTimeFilter: (filter: any) => void;
|
||||
leagueFilter: string;
|
||||
setLeagueFilter: (filter: string) => void;
|
||||
leagues: Array<{ id: string; name: string }>;
|
||||
onShowMoreFilters: () => void;
|
||||
}
|
||||
|
||||
export function RacesCommandBar({
|
||||
timeFilter,
|
||||
setTimeFilter,
|
||||
leagueFilter,
|
||||
setLeagueFilter,
|
||||
leagues,
|
||||
onShowMoreFilters,
|
||||
}: RacesCommandBarProps) {
|
||||
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' },
|
||||
{ id: 'past', label: 'Past' },
|
||||
{ id: 'all', label: 'All' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Surface variant="precision" padding="sm">
|
||||
<Stack direction={{ base: 'col', md: 'row' }} align="center" justify="between" gap={4}>
|
||||
<Stack direction="row" align="center" gap={4} wrap>
|
||||
<SegmentedControl
|
||||
options={timeOptions}
|
||||
activeId={timeFilter}
|
||||
onChange={(id) => setTimeFilter(id)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={leagueFilter}
|
||||
onChange={(e) => setLeagueFilter(e.target.value)}
|
||||
options={leagueOptions}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onShowMoreFilters}
|
||||
icon={<Icon icon={SlidersHorizontal} size={3} />}
|
||||
>
|
||||
Advanced Filters
|
||||
</Button>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
38
apps/website/components/races/RacesDayGroup.tsx
Normal file
38
apps/website/components/races/RacesDayGroup.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { RaceListRow } from './RaceListRow';
|
||||
|
||||
interface RacesDayGroupProps {
|
||||
dateLabel: string;
|
||||
races: any[];
|
||||
onRaceClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export function RacesDayGroup({ dateLabel, races, onRaceClick }: RacesDayGroupProps) {
|
||||
return (
|
||||
<Stack gap={3}>
|
||||
<Box
|
||||
paddingX={4}
|
||||
paddingY={2}
|
||||
borderBottom
|
||||
borderColor="var(--ui-color-border-muted)"
|
||||
>
|
||||
<Text size="xs" variant="low" weight="bold" uppercase letterSpacing="widest">
|
||||
{dateLabel}
|
||||
</Text>
|
||||
</Box>
|
||||
<Stack gap={1}>
|
||||
{races.map(race => (
|
||||
<RaceListRow
|
||||
key={race.id}
|
||||
race={race}
|
||||
onClick={onRaceClick}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
32
apps/website/components/races/RacesEmptyState.tsx
Normal file
32
apps/website/components/races/RacesEmptyState.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
export function RacesEmptyState() {
|
||||
return (
|
||||
<Surface variant="precision" padding="xl">
|
||||
<Stack align="center" justify="center" gap={4} paddingY={12}>
|
||||
<Box
|
||||
width="3rem"
|
||||
height="3rem"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="full"
|
||||
bg="var(--ui-color-bg-surface-muted)"
|
||||
>
|
||||
<Icon icon={Search} size={6} intent="low" />
|
||||
</Box>
|
||||
<Stack gap={1} align="center">
|
||||
<Text size="lg" weight="bold">No races found.</Text>
|
||||
<Text variant="low" size="sm">No races match the current filters.</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
80
apps/website/components/races/RacesLiveRail.tsx
Normal file
80
apps/website/components/races/RacesLiveRail.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use thought';
|
||||
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Zap, ChevronRight } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface RacesLiveRailProps {
|
||||
liveRaces: any[];
|
||||
onRaceClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export function RacesLiveRail({ liveRaces, onRaceClick }: RacesLiveRailProps) {
|
||||
if (liveRaces.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack gap={3}>
|
||||
<Stack direction="row" align="center" gap={2} paddingX={1}>
|
||||
<Icon icon={Zap} size={3} intent="success" />
|
||||
<Text size="xs" variant="success" weight="bold" uppercase letterSpacing="widest">
|
||||
Live Now
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
display="flex"
|
||||
gap={4}
|
||||
overflowX="auto"
|
||||
paddingBottom={2}
|
||||
hideScrollbar
|
||||
>
|
||||
{liveRaces.map(race => (
|
||||
<Surface
|
||||
key={race.id}
|
||||
as={Link}
|
||||
href={`/races/${race.id}`}
|
||||
variant="precision"
|
||||
padding="sm"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onRaceClick(race.id);
|
||||
}}
|
||||
cursor="pointer"
|
||||
minWidth="280px"
|
||||
position="relative"
|
||||
hoverBg="rgba(255, 255, 255, 0.02)"
|
||||
display="block"
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
height="2px"
|
||||
bg="var(--ui-color-intent-success)"
|
||||
/>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" variant="low" weight="bold" uppercase truncate>
|
||||
{race.leagueName}
|
||||
</Text>
|
||||
<Text size="sm" weight="bold" truncate>
|
||||
{race.track}
|
||||
</Text>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Text size="xs" variant="success" weight="bold">
|
||||
{race.timeLabel}
|
||||
</Text>
|
||||
<Icon icon={ChevronRight} size={3} intent="low" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Surface>
|
||||
))}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { StatusDot } from '@/ui/StatusDot';
|
||||
|
||||
export type SessionStatus = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'delayed';
|
||||
|
||||
@@ -10,34 +12,37 @@ interface SessionStatusBadgeProps {
|
||||
}
|
||||
|
||||
export function SessionStatusBadge({ status }: SessionStatusBadgeProps) {
|
||||
const config: Record<SessionStatus, { label: string; variant: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info' }> = {
|
||||
const config: Record<SessionStatus, { label: string; intent: 'primary' | 'success' | 'telemetry' | 'critical' | 'warning' }> = {
|
||||
scheduled: {
|
||||
label: 'SCHEDULED',
|
||||
variant: 'primary',
|
||||
label: 'Scheduled',
|
||||
intent: 'primary',
|
||||
},
|
||||
running: {
|
||||
label: 'LIVE',
|
||||
variant: 'success',
|
||||
label: 'Live',
|
||||
intent: 'success',
|
||||
},
|
||||
completed: {
|
||||
label: 'COMPLETED',
|
||||
variant: 'default',
|
||||
label: 'Finished',
|
||||
intent: 'telemetry',
|
||||
},
|
||||
cancelled: {
|
||||
label: 'CANCELLED',
|
||||
variant: 'danger',
|
||||
label: 'Cancelled',
|
||||
intent: 'critical',
|
||||
},
|
||||
delayed: {
|
||||
label: 'DELAYED',
|
||||
variant: 'warning',
|
||||
label: 'Delayed',
|
||||
intent: 'warning',
|
||||
},
|
||||
};
|
||||
|
||||
const { label, variant } = config[status] || config.scheduled;
|
||||
const { label, intent } = config[status] || config.scheduled;
|
||||
|
||||
return (
|
||||
<Badge variant={variant} size="sm">
|
||||
{label}
|
||||
</Badge>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<StatusDot intent={intent} size={1.5} pulse={status === 'running'} />
|
||||
<Text size="xs" weight="bold" uppercase variant={intent === 'telemetry' ? 'low' : intent} letterSpacing="widest">
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { SidebarItem } from '@/ui/SidebarItem';
|
||||
'use client';
|
||||
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
import React from 'react';
|
||||
|
||||
interface SidebarRaceItemProps {
|
||||
@@ -15,18 +18,38 @@ export function SidebarRaceItem({ race, onClick }: SidebarRaceItemProps) {
|
||||
const scheduledAtDate = new Date(race.scheduledAt);
|
||||
|
||||
return (
|
||||
<SidebarItem
|
||||
<Box
|
||||
onClick={onClick}
|
||||
icon={
|
||||
<Text size="sm" weight="bold" variant="primary">
|
||||
cursor="pointer"
|
||||
p={3}
|
||||
rounded="md"
|
||||
bg="transparent"
|
||||
hoverBg="white/[0.03]"
|
||||
transition
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={3}
|
||||
>
|
||||
<Stack
|
||||
width="10"
|
||||
height="10"
|
||||
align="center"
|
||||
justify="center"
|
||||
bg="var(--ui-color-bg-base)"
|
||||
border
|
||||
borderColor="var(--ui-color-border-default)"
|
||||
rounded="md"
|
||||
>
|
||||
<Text size="xs" weight="bold" variant="primary" mono>
|
||||
{scheduledAtDate.getDate()}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Text size="sm" weight="medium" variant="high" block truncate>{race.track}</Text>
|
||||
<Text size="xs" variant="low" block>
|
||||
{scheduledAtDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
</SidebarItem>
|
||||
</Stack>
|
||||
<Stack gap={0.5} flexGrow={1}>
|
||||
<Text size="sm" weight="bold" variant="high" block truncate>{race.track}</Text>
|
||||
<Text size="xs" variant="telemetry" mono block>
|
||||
{scheduledAtDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
48
apps/website/components/shared/PageHeader.tsx
Normal file
48
apps/website/components/shared/PageHeader.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic PageHeader component following the Teams page style.
|
||||
* Used to maintain visual consistency across main directory pages.
|
||||
*/
|
||||
export function PageHeader({ title, subtitle, action }: PageHeaderProps) {
|
||||
return (
|
||||
<Box
|
||||
marginBottom={12}
|
||||
display="flex"
|
||||
flexDirection={{ base: 'col', md: 'row' }}
|
||||
alignItems={{ base: 'start', md: 'end' }}
|
||||
justifyContent="between"
|
||||
gap={6}
|
||||
borderBottom
|
||||
borderColor="var(--ui-color-border-muted)"
|
||||
paddingBottom={8}
|
||||
>
|
||||
<Box display="flex" flexDirection="col" gap={2}>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box width="4px" height="32px" bg="var(--ui-color-intent-primary)" />
|
||||
<Heading level={1} weight="bold" uppercase>{title}</Heading>
|
||||
</Box>
|
||||
{subtitle && (
|
||||
<Text variant="low" size="lg" uppercase weight="bold" letterSpacing="widest">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{action && (
|
||||
<Box display="flex" alignItems="center">
|
||||
{action}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
118
apps/website/templates/RacesIndexTemplate.tsx
Normal file
118
apps/website/templates/RacesIndexTemplate.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { RacesLiveRail } from '@/components/races/RacesLiveRail';
|
||||
import { RacesCommandBar } from '@/components/races/RacesCommandBar';
|
||||
import { NextUpRacePanel } from '@/components/races/NextUpRacePanel';
|
||||
import { RacesDayGroup } from '@/components/races/RacesDayGroup';
|
||||
import { RacesEmptyState } from '@/components/races/RacesEmptyState';
|
||||
import { RaceFilterModal } from '@/components/races/RaceFilterModal';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
import type { RacesViewData } from '@/lib/view-data/RacesViewData';
|
||||
|
||||
export interface RacesIndexTemplateProps {
|
||||
viewData: RacesViewData & {
|
||||
racesByDate: Array<{
|
||||
dateKey: string;
|
||||
dateLabel: string;
|
||||
races: any[];
|
||||
}>;
|
||||
nextUpRace?: any;
|
||||
};
|
||||
// Filters
|
||||
statusFilter: string;
|
||||
setStatusFilter: (filter: any) => void;
|
||||
leagueFilter: string;
|
||||
setLeagueFilter: (filter: string) => void;
|
||||
timeFilter: string;
|
||||
setTimeFilter: (filter: any) => void;
|
||||
// Actions
|
||||
onRaceClick: (raceId: string) => void;
|
||||
// UI State
|
||||
showFilterModal: boolean;
|
||||
setShowFilterModal: (show: boolean) => void;
|
||||
}
|
||||
|
||||
export function RacesIndexTemplate({
|
||||
viewData,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
leagueFilter,
|
||||
setLeagueFilter,
|
||||
timeFilter,
|
||||
setTimeFilter,
|
||||
onRaceClick,
|
||||
showFilterModal,
|
||||
setShowFilterModal,
|
||||
}: RacesIndexTemplateProps) {
|
||||
const hasRaces = viewData.racesByDate.length > 0;
|
||||
|
||||
return (
|
||||
<Container size="lg">
|
||||
<Stack gap={8} paddingY={12}>
|
||||
<PageHeader
|
||||
title="Races"
|
||||
subtitle="Live Sessions & Upcoming Events"
|
||||
/>
|
||||
|
||||
{/* 1. Status Rail: Live sessions first */}
|
||||
<RacesLiveRail
|
||||
liveRaces={viewData.liveRaces}
|
||||
onRaceClick={onRaceClick}
|
||||
/>
|
||||
|
||||
{/* 2. Command Bar: Fast filters */}
|
||||
<RacesCommandBar
|
||||
timeFilter={timeFilter}
|
||||
setTimeFilter={setTimeFilter}
|
||||
leagueFilter={leagueFilter}
|
||||
setLeagueFilter={setLeagueFilter}
|
||||
leagues={viewData.leagues}
|
||||
onShowMoreFilters={() => setShowFilterModal(true)}
|
||||
/>
|
||||
|
||||
{/* 3. Next Up: High signal panel */}
|
||||
{timeFilter === 'upcoming' && viewData.nextUpRace && (
|
||||
<NextUpRacePanel
|
||||
race={viewData.nextUpRace}
|
||||
onRaceClick={onRaceClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 4. Browse by Day: Grouped schedule */}
|
||||
{hasRaces ? (
|
||||
<Stack gap={8}>
|
||||
{viewData.racesByDate.map((group) => (
|
||||
<RacesDayGroup
|
||||
key={group.dateKey}
|
||||
dateLabel={group.dateLabel}
|
||||
races={group.races}
|
||||
onRaceClick={onRaceClick}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<RacesEmptyState />
|
||||
)}
|
||||
|
||||
<RaceFilterModal
|
||||
isOpen={showFilterModal}
|
||||
onClose={() => setShowFilterModal(false)}
|
||||
statusFilter={statusFilter as any}
|
||||
setStatusFilter={setStatusFilter}
|
||||
leagueFilter={leagueFilter}
|
||||
setLeagueFilter={setLeagueFilter}
|
||||
timeFilter={timeFilter as any}
|
||||
setTimeFilter={setTimeFilter}
|
||||
searchQuery=""
|
||||
setSearchQuery={() => {}}
|
||||
leagues={viewData.leagues}
|
||||
showSearch={true}
|
||||
showTimeFilter={false}
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -11,9 +11,7 @@ import type { RacesViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { GridItem } from '@/ui/GridItem';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import React from 'react';
|
||||
|
||||
export type TimeFilter = 'all' | 'upcoming' | 'live' | 'past';
|
||||
@@ -51,8 +49,8 @@ export function RacesTemplate({
|
||||
setShowFilterModal,
|
||||
}: RacesTemplateProps) {
|
||||
return (
|
||||
<Container size="lg" py={8}>
|
||||
<Group direction="column" gap={8} fullWidth>
|
||||
<Container size="lg">
|
||||
<Stack gap={8}>
|
||||
<RacePageHeader
|
||||
totalCount={viewData.totalCount}
|
||||
scheduledCount={viewData.scheduledCount}
|
||||
@@ -67,7 +65,7 @@ export function RacesTemplate({
|
||||
|
||||
<Grid cols={12} gap={6}>
|
||||
<GridItem colSpan={12} lgSpan={8}>
|
||||
<Group direction="column" gap={6} fullWidth>
|
||||
<Stack gap={6}>
|
||||
<RaceFilterBar
|
||||
timeFilter={timeFilter}
|
||||
setTimeFilter={setTimeFilter}
|
||||
@@ -77,24 +75,18 @@ export function RacesTemplate({
|
||||
onShowMoreFilters={() => setShowFilterModal(true)}
|
||||
/>
|
||||
|
||||
<Panel
|
||||
title="Race Schedule"
|
||||
variant="dark"
|
||||
padding={0}
|
||||
>
|
||||
<RaceScheduleTable
|
||||
races={viewData.races.map(race => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
leagueName: race.leagueName,
|
||||
time: race.timeLabel,
|
||||
status: race.status as SessionStatus
|
||||
}))}
|
||||
onRaceClick={onRaceClick}
|
||||
/>
|
||||
</Panel>
|
||||
</Group>
|
||||
<RaceScheduleTable
|
||||
races={viewData.races.map(race => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
leagueName: race.leagueName,
|
||||
time: race.timeLabel,
|
||||
status: race.status as SessionStatus
|
||||
}))}
|
||||
onRaceClick={onRaceClick}
|
||||
/>
|
||||
</Stack>
|
||||
</GridItem>
|
||||
|
||||
<GridItem colSpan={12} lgSpan={4}>
|
||||
@@ -121,7 +113,7 @@ export function RacesTemplate({
|
||||
showSearch={false}
|
||||
showTimeFilter={false}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
72
apps/website/ui/RaceRow.tsx
Normal file
72
apps/website/ui/RaceRow.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Surface } from './Surface';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export interface RaceRowProps {
|
||||
children: ReactNode;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
status?: 'live' | 'upcoming' | 'past';
|
||||
emphasis?: 'low' | 'medium' | 'high';
|
||||
as?: any;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RaceRow is a semantic UI component for displaying a race in a list.
|
||||
* It encapsulates the visual style of a race entry per the GridPilot theme.
|
||||
*/
|
||||
export const RaceRow = ({
|
||||
children,
|
||||
onClick,
|
||||
status = 'upcoming',
|
||||
emphasis = 'medium',
|
||||
as,
|
||||
href
|
||||
}: RaceRowProps) => {
|
||||
const isLive = status === 'live';
|
||||
|
||||
return (
|
||||
<Surface
|
||||
as={as}
|
||||
href={href}
|
||||
variant="precision"
|
||||
onClick={onClick}
|
||||
padding="none"
|
||||
cursor="pointer"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
transition="all 0.2s ease-in-out"
|
||||
hoverBg="rgba(255, 255, 255, 0.02)"
|
||||
display="block"
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
{isLive && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
bottom={0}
|
||||
width="2px"
|
||||
bg="var(--ui-color-intent-success)"
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={4}
|
||||
paddingX={4}
|
||||
paddingY={3}
|
||||
opacity={emphasis === 'low' ? 0.6 : 1}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={4} flex={1}>
|
||||
{children}
|
||||
</Box>
|
||||
<Box flexShrink={0} display="flex" alignItems="center">
|
||||
<Icon icon={ChevronRight} size={4} intent="low" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
};
|
||||
52
apps/website/ui/RaceRowCell.tsx
Normal file
52
apps/website/ui/RaceRowCell.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
|
||||
export interface RaceRowCellProps {
|
||||
children: ReactNode;
|
||||
label?: string;
|
||||
width?: string | number;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export const RaceRowCell = ({
|
||||
children,
|
||||
label,
|
||||
width,
|
||||
align = 'left',
|
||||
hideOnMobile = false
|
||||
}: RaceRowCellProps) => {
|
||||
const alignmentClasses = {
|
||||
left: 'items-start text-left',
|
||||
center: 'items-center text-center',
|
||||
right: 'items-end text-right'
|
||||
}[align];
|
||||
|
||||
return (
|
||||
<Box
|
||||
display={hideOnMobile ? { base: 'none', md: 'flex' } : 'flex'}
|
||||
flexDirection="col"
|
||||
width={width}
|
||||
flexShrink={width ? 0 : 1}
|
||||
minWidth="0"
|
||||
className={alignmentClasses}
|
||||
>
|
||||
{label && (
|
||||
<Text
|
||||
size="xs"
|
||||
variant="low"
|
||||
uppercase
|
||||
weight="bold"
|
||||
letterSpacing="widest"
|
||||
marginBottom={0.5}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
<Box display="flex" alignItems="center" gap={2} minWidth="0" justifyContent={align === 'right' ? 'end' : (align === 'center' ? 'center' : 'start')}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user