website refactor
This commit is contained in:
@@ -68,10 +68,10 @@ export type RacingSeedOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const racingSeedDefaults: Readonly<
|
export const racingSeedDefaults: Readonly<
|
||||||
Required<RacingSeedOptions>
|
Omit<Required<RacingSeedOptions>, 'baseDate'> & { baseDate: () => Date }
|
||||||
> = {
|
> = {
|
||||||
driverCount: 150, // Increased from 100 to 150
|
driverCount: 150, // Increased from 100 to 150
|
||||||
baseDate: new Date(),
|
baseDate: () => new Date(),
|
||||||
persistence: 'inmemory',
|
persistence: 'inmemory',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ class RacingSeedFactory {
|
|||||||
|
|
||||||
constructor(options: RacingSeedOptions) {
|
constructor(options: RacingSeedOptions) {
|
||||||
this.driverCount = options.driverCount ?? racingSeedDefaults.driverCount;
|
this.driverCount = options.driverCount ?? racingSeedDefaults.driverCount;
|
||||||
this.baseDate = options.baseDate ?? racingSeedDefaults.baseDate;
|
this.baseDate = options.baseDate ?? racingSeedDefaults.baseDate();
|
||||||
this.persistence = options.persistence ?? racingSeedDefaults.persistence;
|
this.persistence = options.persistence ?? racingSeedDefaults.persistence;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,72 +1,67 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
import { RacesIndexTemplate } from '@/templates/RacesIndexTemplate';
|
||||||
import { RacesAllTemplate } from '@/templates/RacesAllTemplate';
|
|
||||||
import { useAllRacesPageData } from '@/hooks/race/useAllRacesPageData';
|
import { useAllRacesPageData } from '@/hooks/race/useAllRacesPageData';
|
||||||
import { type RacesViewData, type RaceViewData } from '@/lib/view-data/RacesViewData';
|
import type { RacesViewData, RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||||
import { Flag } from 'lucide-react';
|
|
||||||
import { routes } from '@/lib/routing/RouteConfig';
|
|
||||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||||
|
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||||
const ITEMS_PER_PAGE = 10;
|
import { Flag } from 'lucide-react';
|
||||||
|
|
||||||
export function RacesAllPageClient({ viewData: initialViewData }: ClientWrapperProps<RacesViewData>) {
|
export function RacesAllPageClient({ viewData: initialViewData }: ClientWrapperProps<RacesViewData>) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
// Client-side state for filters and pagination
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [statusFilter, setStatusFilter] = useState<'scheduled' | 'running' | 'completed' | 'cancelled' | 'all'>('all');
|
|
||||||
const [leagueFilter, setLeagueFilter] = useState<string>('all');
|
const [leagueFilter, setLeagueFilter] = useState<string>('all');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [timeFilter, setTimeFilter] = useState<string>('upcoming');
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
|
||||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||||
|
|
||||||
// Use React Query hook
|
// Use React Query hook
|
||||||
const { data: pageData, isLoading, error, refetch } = useAllRacesPageData(initialViewData);
|
const { data: pageData, isLoading, error, refetch } = useAllRacesPageData(initialViewData);
|
||||||
|
|
||||||
// Transform data
|
const filteredRaces = useMemo(() => {
|
||||||
const races: RaceViewData[] = pageData?.races ?? [];
|
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)
|
if (timeFilter === 'upcoming' && !isActuallyUpcoming) return false;
|
||||||
const filteredRaces = races.filter((race: RaceViewData) => {
|
if (timeFilter === 'live' && !isActuallyLive) return false;
|
||||||
if (statusFilter !== 'all' && race.status !== statusFilter) {
|
if (timeFilter === 'past' && !isActuallyPast) return false;
|
||||||
return false;
|
return true;
|
||||||
}
|
});
|
||||||
|
}, [pageData?.races, statusFilter, leagueFilter, timeFilter]);
|
||||||
|
|
||||||
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
|
const nextUpRace = useMemo(() => {
|
||||||
return false;
|
const now = new Date();
|
||||||
}
|
return filteredRaces.find(r => new Date(r.scheduledAt) > now && r.status.toLowerCase() === 'scheduled');
|
||||||
|
}, [filteredRaces]);
|
||||||
|
|
||||||
if (searchQuery) {
|
const racesByDate = useMemo(() => {
|
||||||
const query = searchQuery.toLowerCase();
|
const grouped = new Map<string, typeof filteredRaces[0][]>();
|
||||||
const matchesTrack = race.track.toLowerCase().includes(query);
|
|
||||||
const matchesCar = race.car.toLowerCase().includes(query);
|
filteredRaces.forEach((race) => {
|
||||||
const matchesLeague = race.leagueName?.toLowerCase().includes(query);
|
const dateKey = race.scheduledAt.split('T')[0]!;
|
||||||
if (!matchesTrack && !matchesCar && !matchesLeague) {
|
if (!grouped.has(dateKey)) {
|
||||||
return false;
|
grouped.set(dateKey, []);
|
||||||
}
|
}
|
||||||
}
|
grouped.get(dateKey)!.push(race);
|
||||||
|
});
|
||||||
|
|
||||||
return true;
|
return Array.from(grouped.entries())
|
||||||
});
|
.sort(([a], [b]) => timeFilter === 'past' ? b.localeCompare(a) : a.localeCompare(b))
|
||||||
|
.map(([dateKey, dayRaces]) => ({
|
||||||
const totalPages = Math.ceil(filteredRaces.length / ITEMS_PER_PAGE);
|
dateKey,
|
||||||
const paginatedRaces = filteredRaces.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
dateLabel: dayRaces[0]?.scheduledAtLabel || '',
|
||||||
|
races: dayRaces,
|
||||||
// Actions
|
}));
|
||||||
const handleRaceClick = (raceId: string) => {
|
}, [filteredRaces, timeFilter]);
|
||||||
router.push(routes.race.detail(raceId));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLeagueClick = (leagueId: string) => {
|
|
||||||
router.push(routes.league.detail(leagueId));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
setCurrentPage(page);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatefulPageWrapper
|
<StatefulPageWrapper
|
||||||
@@ -75,27 +70,22 @@ export function RacesAllPageClient({ viewData: initialViewData }: ClientWrapperP
|
|||||||
error={error as Error | null}
|
error={error as Error | null}
|
||||||
retry={refetch}
|
retry={refetch}
|
||||||
Template={() => pageData ? (
|
Template={() => pageData ? (
|
||||||
<RacesAllTemplate
|
<RacesIndexTemplate
|
||||||
viewData={pageData}
|
viewData={{
|
||||||
races={paginatedRaces}
|
...pageData,
|
||||||
totalFilteredCount={filteredRaces.length}
|
races: filteredRaces,
|
||||||
isLoading={false}
|
racesByDate,
|
||||||
currentPage={currentPage}
|
nextUpRace,
|
||||||
totalPages={totalPages}
|
}}
|
||||||
itemsPerPage={ITEMS_PER_PAGE}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
statusFilter={statusFilter}
|
statusFilter={statusFilter}
|
||||||
setStatusFilter={setStatusFilter}
|
setStatusFilter={setStatusFilter}
|
||||||
leagueFilter={leagueFilter}
|
leagueFilter={leagueFilter}
|
||||||
setLeagueFilter={setLeagueFilter}
|
setLeagueFilter={setLeagueFilter}
|
||||||
searchQuery={searchQuery}
|
timeFilter={timeFilter}
|
||||||
setSearchQuery={setSearchQuery}
|
setTimeFilter={setTimeFilter}
|
||||||
showFilters={showFilters}
|
|
||||||
setShowFilters={setShowFilters}
|
|
||||||
showFilterModal={showFilterModal}
|
showFilterModal={showFilterModal}
|
||||||
setShowFilterModal={setShowFilterModal}
|
setShowFilterModal={setShowFilterModal}
|
||||||
onRaceClick={handleRaceClick}
|
onRaceClick={(id) => router.push(`/races/${id}`)}
|
||||||
onLeagueClick={handleLeagueClick}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
loading={{ variant: 'skeleton', message: 'Loading races...' }}
|
loading={{ variant: 'skeleton', message: 'Loading races...' }}
|
||||||
|
|||||||
@@ -2,30 +2,54 @@
|
|||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 type { RacesViewData } from '@/lib/view-data/RacesViewData';
|
||||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||||
|
|
||||||
export function RacesPageClient({ viewData }: ClientWrapperProps<RacesViewData>) {
|
export function RacesPageClient({ viewData }: ClientWrapperProps<RacesViewData>) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [statusFilter, setStatusFilter] = useState<RaceStatusFilter>('all');
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
const [leagueFilter, setLeagueFilter] = 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 [showFilterModal, setShowFilterModal] = useState(false);
|
||||||
|
|
||||||
const filteredRaces = useMemo(() => {
|
const filteredRaces = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
return viewData.races.filter((race) => {
|
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 (leagueFilter !== 'all' && race.leagueId !== leagueFilter) return false;
|
||||||
if (timeFilter === 'upcoming' && !race.isUpcoming) return false;
|
|
||||||
if (timeFilter === 'live' && !race.isLive) return false;
|
// Time filter: ensure we are checking the correct flags
|
||||||
if (timeFilter === 'past' && !race.isPast) return false;
|
// 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;
|
return true;
|
||||||
});
|
});
|
||||||
}, [viewData.races, statusFilter, leagueFilter, timeFilter]);
|
}, [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 racesByDate = useMemo(() => {
|
||||||
const grouped = new Map<string, typeof filteredRaces[0][]>();
|
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) => {
|
filteredRaces.forEach((race) => {
|
||||||
const dateKey = race.scheduledAt.split('T')[0]!;
|
const dateKey = race.scheduledAt.split('T')[0]!;
|
||||||
if (!grouped.has(dateKey)) {
|
if (!grouped.has(dateKey)) {
|
||||||
@@ -33,19 +57,23 @@ export function RacesPageClient({ viewData }: ClientWrapperProps<RacesViewData>)
|
|||||||
}
|
}
|
||||||
grouped.get(dateKey)!.push(race);
|
grouped.get(dateKey)!.push(race);
|
||||||
});
|
});
|
||||||
return Array.from(grouped.entries()).map(([dateKey, dayRaces]) => ({
|
|
||||||
dateKey,
|
return Array.from(grouped.entries())
|
||||||
dateLabel: dayRaces[0]?.scheduledAtLabel || '',
|
.sort(([a], [b]) => timeFilter === 'past' ? b.localeCompare(a) : a.localeCompare(b))
|
||||||
races: dayRaces,
|
.map(([dateKey, dayRaces]) => ({
|
||||||
}));
|
dateKey,
|
||||||
}, [filteredRaces]);
|
dateLabel: dayRaces[0]?.scheduledAtLabel || '',
|
||||||
|
races: dayRaces,
|
||||||
|
}));
|
||||||
|
}, [filteredRaces, timeFilter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RacesTemplate
|
<RacesIndexTemplate
|
||||||
viewData={{
|
viewData={{
|
||||||
...viewData,
|
...viewData,
|
||||||
races: filteredRaces,
|
races: filteredRaces,
|
||||||
racesByDate,
|
racesByDate,
|
||||||
|
nextUpRace,
|
||||||
}}
|
}}
|
||||||
statusFilter={statusFilter}
|
statusFilter={statusFilter}
|
||||||
setStatusFilter={setStatusFilter}
|
setStatusFilter={setStatusFilter}
|
||||||
@@ -56,9 +84,6 @@ export function RacesPageClient({ viewData }: ClientWrapperProps<RacesViewData>)
|
|||||||
showFilterModal={showFilterModal}
|
showFilterModal={showFilterModal}
|
||||||
setShowFilterModal={setShowFilterModal}
|
setShowFilterModal={setShowFilterModal}
|
||||||
onRaceClick={(id) => router.push(`/races/${id}`)}
|
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 { Heading } from '@/ui/Heading';
|
||||||
import { Icon } from '@/ui/Icon';
|
import { Icon } from '@/ui/Icon';
|
||||||
import { Text } from '@/ui/Text';
|
import { Text } from '@/ui/Text';
|
||||||
|
import { Stack } from '@/ui/Stack';
|
||||||
|
import { Panel } from '@/ui/Panel';
|
||||||
import { ChevronRight, PlayCircle } from 'lucide-react';
|
import { ChevronRight, PlayCircle } from 'lucide-react';
|
||||||
|
|
||||||
interface LiveRaceItemProps {
|
interface LiveRaceItemProps {
|
||||||
@@ -14,30 +15,21 @@ interface LiveRaceItemProps {
|
|||||||
|
|
||||||
export function LiveRaceItem({ track, leagueName, onClick }: LiveRaceItemProps) {
|
export function LiveRaceItem({ track, leagueName, onClick }: LiveRaceItemProps) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Panel
|
||||||
|
variant="precision"
|
||||||
|
padding="sm"
|
||||||
onClick={onClick}
|
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}>
|
<Stack direction="row" align="center" justify="between" fullWidth>
|
||||||
<Box p={2} bg="bg-performance-green/20" rounded="lg">
|
<Stack direction="row" align="center" gap={4}>
|
||||||
<Icon icon={PlayCircle} size={5} color="rgb(16, 185, 129)" />
|
<Icon icon={PlayCircle} size={5} intent="success" animate="pulse" />
|
||||||
</Box>
|
<Stack gap={0.5}>
|
||||||
<Box>
|
<Heading level={5} weight="bold">{track}</Heading>
|
||||||
<Heading level={3}>{track}</Heading>
|
<Text size="xs" variant="low" uppercase letterSpacing="widest">{leagueName}</Text>
|
||||||
<Text size="sm" color="text-gray-400">{leagueName}</Text>
|
</Stack>
|
||||||
</Box>
|
</Stack>
|
||||||
</Box>
|
<Icon icon={ChevronRight} size={4} intent="low" />
|
||||||
<Icon icon={ChevronRight} size={5} color="rgb(156, 163, 175)" />
|
</Stack>
|
||||||
</Box>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { LiveRaceItem } from '@/components/races/LiveRaceItem';
|
import { LiveRaceItem } from '@/components/races/LiveRaceItem';
|
||||||
import type { RaceViewData } from '@/lib/view-data/RacesViewData';
|
import type { RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||||
import { Stack } from '@/ui/Stack';
|
import { Stack } from '@/ui/Stack';
|
||||||
import { Text } from '@/ui/Text';
|
import { Text } from '@/ui/Text';
|
||||||
|
import { Panel } from '@/ui/Panel';
|
||||||
|
import { Icon } from '@/ui/Icon';
|
||||||
|
import { Zap } from 'lucide-react';
|
||||||
|
|
||||||
interface LiveRacesBannerProps {
|
interface LiveRacesBannerProps {
|
||||||
liveRaces: RaceViewData[];
|
liveRaces: RaceViewData[];
|
||||||
@@ -14,45 +17,26 @@ export function LiveRacesBanner({ liveRaces, onRaceClick }: LiveRacesBannerProps
|
|||||||
if (liveRaces.length === 0) return null;
|
if (liveRaces.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Panel variant="glass" padding="md">
|
||||||
position="relative"
|
<Stack gap={4}>
|
||||||
overflow="hidden"
|
<Stack direction="row" align="center" gap={2}>
|
||||||
rounded="xl"
|
<Icon icon={Zap} size={4} intent="success" animate="pulse" />
|
||||||
p={6}
|
<Text weight="bold" size="sm" variant="success" uppercase letterSpacing="widest">
|
||||||
border
|
Live Sessions
|
||||||
borderColor="border-performance-green/30"
|
</Text>
|
||||||
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>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack gap={3}>
|
<Stack gap={2}>
|
||||||
{liveRaces.map((race) => (
|
{liveRaces.map((race) => (
|
||||||
<LiveRaceItem
|
<LiveRaceItem
|
||||||
key={race.id}
|
key={race.id}
|
||||||
track={race.track}
|
track={race.track}
|
||||||
leagueName={race.leagueName ?? 'Unknown League'}
|
leagueName={race.leagueName ?? 'Official'}
|
||||||
onClick={() => onRaceClick(race.id)}
|
onClick={() => onRaceClick(race.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</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 type { TimeFilter } from '@/templates/RacesTemplate';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
@@ -6,6 +6,8 @@ import { Card } from '@/ui/Card';
|
|||||||
import { FilterGroup } from '@/ui/FilterGroup';
|
import { FilterGroup } from '@/ui/FilterGroup';
|
||||||
import { Select } from '@/ui/Select';
|
import { Select } from '@/ui/Select';
|
||||||
import { Stack } from '@/ui/Stack';
|
import { Stack } from '@/ui/Stack';
|
||||||
|
import { Icon } from '@/ui/Icon';
|
||||||
|
import { SlidersHorizontal } from 'lucide-react';
|
||||||
|
|
||||||
interface RaceFilterBarProps {
|
interface RaceFilterBarProps {
|
||||||
timeFilter: TimeFilter;
|
timeFilter: TimeFilter;
|
||||||
@@ -31,32 +33,36 @@ export function RaceFilterBar({
|
|||||||
|
|
||||||
const timeOptions = [
|
const timeOptions = [
|
||||||
{ id: 'upcoming', label: 'Upcoming' },
|
{ id: 'upcoming', label: 'Upcoming' },
|
||||||
{ id: 'live', label: 'Live', indicatorColor: 'bg-performance-green' },
|
{ id: 'live', label: 'Live' },
|
||||||
{ id: 'past', label: 'Past' },
|
{ id: 'past', label: 'Past' },
|
||||||
{ id: 'all', label: 'All' },
|
{ id: 'all', label: 'All' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card p={4}>
|
<Card variant="precision" padding="sm">
|
||||||
<Stack direction="row" align="center" gap={4} wrap>
|
<Stack direction="row" align="center" justify="between" wrap gap={4}>
|
||||||
<FilterGroup
|
<Stack direction="row" align="center" gap={4} wrap>
|
||||||
options={timeOptions}
|
<FilterGroup
|
||||||
activeId={timeFilter}
|
options={timeOptions}
|
||||||
onSelect={(id) => setTimeFilter(id as TimeFilter)}
|
activeId={timeFilter}
|
||||||
/>
|
onSelect={(id) => setTimeFilter(id as TimeFilter)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={leagueFilter}
|
value={leagueFilter}
|
||||||
onChange={(e) => setLeagueFilter(e.target.value)}
|
onChange={(e) => setLeagueFilter(e.target.value)}
|
||||||
options={leagueOptions}
|
options={leagueOptions}
|
||||||
fullWidth={false}
|
fullWidth={false}
|
||||||
/>
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
onClick={onShowMoreFilters}
|
onClick={onShowMoreFilters}
|
||||||
|
icon={<Icon icon={SlidersHorizontal} size={3} />}
|
||||||
>
|
>
|
||||||
More Filters
|
Filters
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Modal } from '@/components/shared/Modal';
|
|||||||
import { Stack } from '@/ui/Stack';
|
import { Stack } from '@/ui/Stack';
|
||||||
import { Select } from '@/ui/Select';
|
import { Select } from '@/ui/Select';
|
||||||
import { Text } from '@/ui/Text';
|
import { Text } from '@/ui/Text';
|
||||||
|
import { StatusDot } from '@/ui/StatusDot';
|
||||||
import { Filter, Search } from 'lucide-react';
|
import { Filter, Search } from 'lucide-react';
|
||||||
|
|
||||||
export type TimeFilter = 'all' | 'upcoming' | 'live' | 'past';
|
export type TimeFilter = 'all' | 'upcoming' | 'live' | 'past';
|
||||||
@@ -48,9 +49,9 @@ export function RaceFilterModal({
|
|||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onOpenChange={(open) => !open && onClose()}
|
onOpenChange={(open) => !open && onClose()}
|
||||||
title="Filters"
|
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 */}
|
{/* Search */}
|
||||||
{showSearch && (
|
{showSearch && (
|
||||||
<Input
|
<Input
|
||||||
@@ -59,15 +60,15 @@ export function RaceFilterModal({
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="Track, car, or league..."
|
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 */}
|
{/* Time Filter */}
|
||||||
{showTimeFilter && (
|
{showTimeFilter && (
|
||||||
<Stack>
|
<Stack gap={2}>
|
||||||
<Text as="label" size="sm" color="text-gray-400" block mb={2}>Time</Text>
|
<Text as="label" size="xs" variant="low" weight="bold" uppercase letterSpacing="widest">Time</Text>
|
||||||
<Stack display="flex" flexWrap="wrap" gap={2}>
|
<Stack direction="row" wrap gap={2}>
|
||||||
{(['upcoming', 'live', 'past', 'all'] as TimeFilter[]).map(filter => (
|
{(['upcoming', 'live', 'past', 'all'] as TimeFilter[]).map(filter => (
|
||||||
<Button
|
<Button
|
||||||
key={filter}
|
key={filter}
|
||||||
@@ -75,7 +76,11 @@ export function RaceFilterModal({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setTimeFilter(filter)}
|
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)}
|
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
@@ -111,7 +116,7 @@ export function RaceFilterModal({
|
|||||||
{/* Clear Filters */}
|
{/* Clear Filters */}
|
||||||
{(statusFilter !== 'all' || leagueFilter !== 'all' || searchQuery || (showTimeFilter && timeFilter !== 'upcoming')) && (
|
{(statusFilter !== 'all' || leagueFilter !== 'all' || searchQuery || (showTimeFilter && timeFilter !== 'upcoming')) && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStatusFilter('all');
|
setStatusFilter('all');
|
||||||
setLeagueFilter('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 { Icon } from '@/ui/Icon';
|
||||||
import { Grid } from '@/ui/Grid';
|
import { Grid } from '@/ui/Grid';
|
||||||
import { Stack } from '@/ui/Stack';
|
import { Stack } from '@/ui/Stack';
|
||||||
import { Surface } from '@/ui/Surface';
|
import { Panel } from '@/ui/Panel';
|
||||||
import { Text } from '@/ui/Text';
|
import { Text } from '@/ui/Text';
|
||||||
import { CalendarDays, Clock, Flag, LucideIcon, Trophy, Zap } from 'lucide-react';
|
import { CalendarDays, Clock, Flag, LucideIcon, Trophy, Zap } from 'lucide-react';
|
||||||
|
|
||||||
@@ -22,57 +22,50 @@ export function RacePageHeader({
|
|||||||
completedCount,
|
completedCount,
|
||||||
}: RacePageHeaderProps) {
|
}: RacePageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<Surface
|
<Panel
|
||||||
bg="bg-surface-charcoal"
|
variant="precision"
|
||||||
rounded="xl"
|
padding="lg"
|
||||||
border
|
|
||||||
borderColor="border-outline-steel"
|
|
||||||
padding={6}
|
|
||||||
position="relative"
|
|
||||||
overflow="hidden"
|
|
||||||
>
|
>
|
||||||
{/* Background Accent */}
|
<Stack gap={8}>
|
||||||
<Stack
|
|
||||||
position="absolute"
|
|
||||||
top={0}
|
|
||||||
left={0}
|
|
||||||
right={0}
|
|
||||||
height="1"
|
|
||||||
bg="bg-primary-accent"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack gap={6}>
|
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Stack direction="row" align="center" gap={3}>
|
<Stack direction="row" align="center" gap={3}>
|
||||||
<Icon icon={Flag} size={6} color="var(--primary-accent)" />
|
<Icon icon={Flag} size={6} intent="primary" />
|
||||||
<Heading level={1}>RACE DASHBOARD</Heading>
|
<Heading level={1} uppercase weight="bold">Race Dashboard</Heading>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Text color="text-gray-400" size="sm">
|
<Text variant="low" size="sm">
|
||||||
Precision tracking for upcoming sessions and live events.
|
Precision tracking for upcoming sessions and live events.
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Grid cols={2} mdCols={4} gap={4}>
|
<Grid cols={2} mdCols={4} gap={6}>
|
||||||
<StatItem icon={CalendarDays} label="TOTAL SESSIONS" value={totalCount} />
|
<StatItem icon={CalendarDays} label="Total Sessions" value={totalCount} />
|
||||||
<StatItem icon={Clock} label="SCHEDULED" value={scheduledCount} color="text-primary-accent" />
|
<StatItem icon={Clock} label="Scheduled" value={scheduledCount} variant="primary" />
|
||||||
<StatItem icon={Zap} label="LIVE NOW" value={runningCount} color="text-success-green" />
|
<StatItem icon={Zap} label="Live Now" value={runningCount} variant="success" />
|
||||||
<StatItem icon={Trophy} label="COMPLETED" value={completedCount} color="text-gray-400" />
|
<StatItem icon={Trophy} label="Completed" value={completedCount} variant="low" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Stack>
|
</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 (
|
return (
|
||||||
<Stack p={4} bg="bg-base-black" bgOpacity={0.5} border borderColor="border-outline-steel">
|
<Stack gap={2}>
|
||||||
<Stack gap={1}>
|
<Stack direction="row" align="center" gap={2}>
|
||||||
<Stack direction="row" align="center" gap={2}>
|
<Icon icon={icon} size={3} intent={variant === 'high' ? 'low' : variant} />
|
||||||
<Icon icon={icon} size={3} color={color === 'text-white' ? '#9ca3af' : undefined} groupHoverTextColor={color !== 'text-white' ? color : undefined} />
|
<Text size="xs" variant="low" weight="bold" uppercase letterSpacing="widest">{label}</Text>
|
||||||
<Text size="xs" color="text-gray-500" weight="bold" uppercase>{label}</Text>
|
|
||||||
</Stack>
|
|
||||||
<Text size="2xl" weight="bold" color={color}>{value}</Text>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<Text size="3xl" weight="bold" variant={variant} mono>{value}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React from 'react';
|
|||||||
import { Text } from '@/ui/Text';
|
import { Text } from '@/ui/Text';
|
||||||
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
|
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
|
||||||
import { SessionStatusBadge, type SessionStatus } from './SessionStatusBadge';
|
import { SessionStatusBadge, type SessionStatus } from './SessionStatusBadge';
|
||||||
|
import { Stack } from '@/ui/Stack';
|
||||||
|
|
||||||
interface RaceRow {
|
interface RaceRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,8 +26,7 @@ export function RaceScheduleTable({ races, onRaceClick }: RaceScheduleTableProps
|
|||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHeader>Time</TableHeader>
|
<TableHeader>Time</TableHeader>
|
||||||
<TableHeader>Track</TableHeader>
|
<TableHeader>Session Details</TableHeader>
|
||||||
<TableHeader>Car</TableHeader>
|
|
||||||
<TableHeader>League</TableHeader>
|
<TableHeader>League</TableHeader>
|
||||||
<TableHeader textAlign="right">Status</TableHeader>
|
<TableHeader textAlign="right">Status</TableHeader>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -39,21 +39,23 @@ export function RaceScheduleTable({ races, onRaceClick }: RaceScheduleTableProps
|
|||||||
clickable
|
clickable
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Text size="xs" variant="telemetry" weight="bold">{race.time}</Text>
|
<Text size="xs" variant="telemetry" weight="bold" mono>{race.time}</Text>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Text size="sm" weight="bold" variant="high">
|
<Stack gap={0.5}>
|
||||||
{race.track}
|
<Text size="sm" weight="bold" variant="high">
|
||||||
</Text>
|
{race.track}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" variant="low" uppercase letterSpacing="widest">{race.car}</Text>
|
||||||
|
</Stack>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Text size="xs" variant="low">{race.car}</Text>
|
<Text size="xs" variant="low" weight="medium">{race.leagueName || 'Official'}</Text>
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Text size="xs" variant="low">{race.leagueName || 'Official'}</Text>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell textAlign="right">
|
<TableCell textAlign="right">
|
||||||
<SessionStatusBadge status={race.status} />
|
<Stack direction="row" justify="end">
|
||||||
|
<SessionStatusBadge status={race.status} />
|
||||||
|
</Stack>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ import { SidebarRaceItem } from '@/components/races/SidebarRaceItem';
|
|||||||
import { routes } from '@/lib/routing/RouteConfig';
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
import type { RaceViewData } from '@/lib/view-data/RacesViewData';
|
import type { RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||||
import { Panel } from '@/ui/Panel';
|
import { Panel } from '@/ui/Panel';
|
||||||
import { Icon } from '@/ui/Icon';
|
|
||||||
import { SidebarActionLink } from '@/ui/SidebarActionLink';
|
import { SidebarActionLink } from '@/ui/SidebarActionLink';
|
||||||
import { Text } from '@/ui/Text';
|
import { Text } from '@/ui/Text';
|
||||||
import { Stack } from '@/ui/Stack';
|
import { Stack } from '@/ui/Stack';
|
||||||
import { Box } from '@/ui/Box';
|
import { Box } from '@/ui/Box';
|
||||||
import { Clock, Trophy, Users } from 'lucide-react';
|
import { Trophy, Users } from 'lucide-react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
interface RaceSidebarProps {
|
interface RaceSidebarProps {
|
||||||
@@ -23,15 +22,16 @@ export function RaceSidebar({ upcomingRaces, recentResults, onRaceClick }: RaceS
|
|||||||
<Stack gap={6}>
|
<Stack gap={6}>
|
||||||
{/* Upcoming This Week */}
|
{/* Upcoming This Week */}
|
||||||
<Panel
|
<Panel
|
||||||
|
variant="precision"
|
||||||
title="Next Up"
|
title="Next Up"
|
||||||
description="This week"
|
description="Scheduled sessions"
|
||||||
>
|
>
|
||||||
{upcomingRaces.length === 0 ? (
|
{upcomingRaces.length === 0 ? (
|
||||||
<Box paddingY={4} textAlign="center">
|
<Box paddingY={4} textAlign="center">
|
||||||
<Text size="sm" variant="low">No races scheduled this week</Text>
|
<Text size="sm" variant="low">No races scheduled this week</Text>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap={3}>
|
<Stack gap={1}>
|
||||||
{upcomingRaces.map((race) => (
|
{upcomingRaces.map((race) => (
|
||||||
<SidebarRaceItem
|
<SidebarRaceItem
|
||||||
key={race.id}
|
key={race.id}
|
||||||
@@ -49,14 +49,16 @@ export function RaceSidebar({ upcomingRaces, recentResults, onRaceClick }: RaceS
|
|||||||
|
|
||||||
{/* Recent Results */}
|
{/* Recent Results */}
|
||||||
<Panel
|
<Panel
|
||||||
|
variant="precision"
|
||||||
title="Recent Results"
|
title="Recent Results"
|
||||||
|
description="Latest finishes"
|
||||||
>
|
>
|
||||||
{recentResults.length === 0 ? (
|
{recentResults.length === 0 ? (
|
||||||
<Box paddingY={4} textAlign="center">
|
<Box paddingY={4} textAlign="center">
|
||||||
<Text size="sm" variant="low">No completed races yet</Text>
|
<Text size="sm" variant="low">No completed races yet</Text>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap={3}>
|
<Stack gap={1}>
|
||||||
{recentResults.map((race) => (
|
{recentResults.map((race) => (
|
||||||
<SidebarRaceItem
|
<SidebarRaceItem
|
||||||
key={race.id}
|
key={race.id}
|
||||||
@@ -73,7 +75,7 @@ export function RaceSidebar({ upcomingRaces, recentResults, onRaceClick }: RaceS
|
|||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<Panel title="Quick Actions">
|
<Panel variant="precision" title="Quick Actions">
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<SidebarActionLink
|
<SidebarActionLink
|
||||||
href={routes.public.leagues}
|
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';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
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';
|
export type SessionStatus = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'delayed';
|
||||||
|
|
||||||
@@ -10,34 +12,37 @@ interface SessionStatusBadgeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SessionStatusBadge({ status }: 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: {
|
scheduled: {
|
||||||
label: 'SCHEDULED',
|
label: 'Scheduled',
|
||||||
variant: 'primary',
|
intent: 'primary',
|
||||||
},
|
},
|
||||||
running: {
|
running: {
|
||||||
label: 'LIVE',
|
label: 'Live',
|
||||||
variant: 'success',
|
intent: 'success',
|
||||||
},
|
},
|
||||||
completed: {
|
completed: {
|
||||||
label: 'COMPLETED',
|
label: 'Finished',
|
||||||
variant: 'default',
|
intent: 'telemetry',
|
||||||
},
|
},
|
||||||
cancelled: {
|
cancelled: {
|
||||||
label: 'CANCELLED',
|
label: 'Cancelled',
|
||||||
variant: 'danger',
|
intent: 'critical',
|
||||||
},
|
},
|
||||||
delayed: {
|
delayed: {
|
||||||
label: 'DELAYED',
|
label: 'Delayed',
|
||||||
variant: 'warning',
|
intent: 'warning',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const { label, variant } = config[status] || config.scheduled;
|
const { label, intent } = config[status] || config.scheduled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge variant={variant} size="sm">
|
<Stack direction="row" align="center" gap={2}>
|
||||||
{label}
|
<StatusDot intent={intent} size={1.5} pulse={status === 'running'} />
|
||||||
</Badge>
|
<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 { Text } from '@/ui/Text';
|
||||||
|
import { Stack } from '@/ui/Stack';
|
||||||
|
import { Box } from '@/ui/Box';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
interface SidebarRaceItemProps {
|
interface SidebarRaceItemProps {
|
||||||
@@ -15,18 +18,38 @@ export function SidebarRaceItem({ race, onClick }: SidebarRaceItemProps) {
|
|||||||
const scheduledAtDate = new Date(race.scheduledAt);
|
const scheduledAtDate = new Date(race.scheduledAt);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarItem
|
<Box
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
icon={
|
cursor="pointer"
|
||||||
<Text size="sm" weight="bold" variant="primary">
|
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()}
|
{scheduledAtDate.getDate()}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
</Stack>
|
||||||
>
|
<Stack gap={0.5} flexGrow={1}>
|
||||||
<Text size="sm" weight="medium" variant="high" block truncate>{race.track}</Text>
|
<Text size="sm" weight="bold" variant="high" block truncate>{race.track}</Text>
|
||||||
<Text size="xs" variant="low" block>
|
<Text size="xs" variant="telemetry" mono block>
|
||||||
{scheduledAtDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
{scheduledAtDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
</Text>
|
</Text>
|
||||||
</SidebarItem>
|
</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 { Container } from '@/ui/Container';
|
||||||
import { Grid } from '@/ui/Grid';
|
import { Grid } from '@/ui/Grid';
|
||||||
import { GridItem } from '@/ui/GridItem';
|
import { GridItem } from '@/ui/GridItem';
|
||||||
import { Group } from '@/ui/Group';
|
import { Stack } from '@/ui/Stack';
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
import { Panel } from '@/ui/Panel';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export type TimeFilter = 'all' | 'upcoming' | 'live' | 'past';
|
export type TimeFilter = 'all' | 'upcoming' | 'live' | 'past';
|
||||||
@@ -51,8 +49,8 @@ export function RacesTemplate({
|
|||||||
setShowFilterModal,
|
setShowFilterModal,
|
||||||
}: RacesTemplateProps) {
|
}: RacesTemplateProps) {
|
||||||
return (
|
return (
|
||||||
<Container size="lg" py={8}>
|
<Container size="lg">
|
||||||
<Group direction="column" gap={8} fullWidth>
|
<Stack gap={8}>
|
||||||
<RacePageHeader
|
<RacePageHeader
|
||||||
totalCount={viewData.totalCount}
|
totalCount={viewData.totalCount}
|
||||||
scheduledCount={viewData.scheduledCount}
|
scheduledCount={viewData.scheduledCount}
|
||||||
@@ -67,7 +65,7 @@ export function RacesTemplate({
|
|||||||
|
|
||||||
<Grid cols={12} gap={6}>
|
<Grid cols={12} gap={6}>
|
||||||
<GridItem colSpan={12} lgSpan={8}>
|
<GridItem colSpan={12} lgSpan={8}>
|
||||||
<Group direction="column" gap={6} fullWidth>
|
<Stack gap={6}>
|
||||||
<RaceFilterBar
|
<RaceFilterBar
|
||||||
timeFilter={timeFilter}
|
timeFilter={timeFilter}
|
||||||
setTimeFilter={setTimeFilter}
|
setTimeFilter={setTimeFilter}
|
||||||
@@ -77,24 +75,18 @@ export function RacesTemplate({
|
|||||||
onShowMoreFilters={() => setShowFilterModal(true)}
|
onShowMoreFilters={() => setShowFilterModal(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Panel
|
<RaceScheduleTable
|
||||||
title="Race Schedule"
|
races={viewData.races.map(race => ({
|
||||||
variant="dark"
|
id: race.id,
|
||||||
padding={0}
|
track: race.track,
|
||||||
>
|
car: race.car,
|
||||||
<RaceScheduleTable
|
leagueName: race.leagueName,
|
||||||
races={viewData.races.map(race => ({
|
time: race.timeLabel,
|
||||||
id: race.id,
|
status: race.status as SessionStatus
|
||||||
track: race.track,
|
}))}
|
||||||
car: race.car,
|
onRaceClick={onRaceClick}
|
||||||
leagueName: race.leagueName,
|
/>
|
||||||
time: race.timeLabel,
|
</Stack>
|
||||||
status: race.status as SessionStatus
|
|
||||||
}))}
|
|
||||||
onRaceClick={onRaceClick}
|
|
||||||
/>
|
|
||||||
</Panel>
|
|
||||||
</Group>
|
|
||||||
</GridItem>
|
</GridItem>
|
||||||
|
|
||||||
<GridItem colSpan={12} lgSpan={4}>
|
<GridItem colSpan={12} lgSpan={4}>
|
||||||
@@ -121,7 +113,7 @@ export function RacesTemplate({
|
|||||||
showSearch={false}
|
showSearch={false}
|
||||||
showTimeFilter={false}
|
showTimeFilter={false}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Stack>
|
||||||
</Container>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
15
package.json
15
package.json
@@ -88,11 +88,15 @@
|
|||||||
"docker:dev:logs": "sh -lc \"set -e; if docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps -q 2>/dev/null | grep -q .; then docker-compose -p gridpilot-dev -f docker-compose.dev.yml logs -f; else echo '[docker] No running containers to show logs for'; echo '[docker] Start with: npm run docker:dev'; fi\"",
|
"docker:dev:logs": "sh -lc \"set -e; if docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps -q 2>/dev/null | grep -q .; then docker-compose -p gridpilot-dev -f docker-compose.dev.yml logs -f; else echo '[docker] No running containers to show logs for'; echo '[docker] Start with: npm run docker:dev'; fi\"",
|
||||||
"docker:dev:postgres": "sh -lc \"GRIDPILOT_API_PERSISTENCE=postgres npm run docker:dev:up\"",
|
"docker:dev:postgres": "sh -lc \"GRIDPILOT_API_PERSISTENCE=postgres npm run docker:dev:up\"",
|
||||||
"docker:dev:ps": "sh -lc \"set -e; echo '[docker] Container status:'; docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps; echo ''; echo '[docker] Running containers:'; docker ps --filter name=gridpilot-dev --format 'table {{.Names}}\\t{{.Status}}\\t{{.Ports}}'\"",
|
"docker:dev:ps": "sh -lc \"set -e; echo '[docker] Container status:'; docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps; echo ''; echo '[docker] Running containers:'; docker ps --filter name=gridpilot-dev --format 'table {{.Names}}\\t{{.Status}}\\t{{.Ports}}'\"",
|
||||||
|
"docker:dev:reseed": "GRIDPILOT_API_FORCE_RESEED=true npm run docker:dev",
|
||||||
"docker:dev:restart": "sh -lc \"set -e; echo '[docker] Restarting services...'; if docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps -q 2>/dev/null | grep -q .; then docker-compose -p gridpilot-dev -f docker-compose.dev.yml restart; echo '[docker] Restarted'; else echo '[docker] No running containers to restart'; echo '[docker] Start with: npm run docker:dev'; fi\"",
|
"docker:dev:restart": "sh -lc \"set -e; echo '[docker] Restarting services...'; if docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps -q 2>/dev/null | grep -q .; then docker-compose -p gridpilot-dev -f docker-compose.dev.yml restart; echo '[docker] Restarted'; else echo '[docker] No running containers to restart'; echo '[docker] Start with: npm run docker:dev'; fi\"",
|
||||||
"docker:dev:status": "sh -lc \"set -e; echo '[docker] Checking dev environment status...'; if docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps -q 2>/dev/null | grep -q .; then echo '[docker] ✓ Environment is RUNNING'; docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps; echo ''; echo '[docker] Services health:'; docker ps --filter name=gridpilot-dev --format 'table {{.Names}}\\t{{.Status}}\\t{{.RunningFor}}'; else echo '[docker] ✗ Environment is STOPPED'; echo '[docker] Start with: npm run docker:dev'; fi\"",
|
"docker:dev:status": "sh -lc \"set -e; echo '[docker] Checking dev environment status...'; if docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps -q 2>/dev/null | grep -q .; then echo '[docker] ✓ Environment is RUNNING'; docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps; echo ''; echo '[docker] Services health:'; docker ps --filter name=gridpilot-dev --format 'table {{.Names}}\\t{{.Status}}\\t{{.RunningFor}}'; else echo '[docker] ✗ Environment is STOPPED'; echo '[docker] Start with: npm run docker:dev'; fi\"",
|
||||||
"docker:dev:up": "sh -lc \"set -e; echo '[docker] Starting dev environment...'; if docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps -q 2>/dev/null | grep -q .; then echo '[docker] Already running, attaching to logs...'; docker-compose -p gridpilot-dev -f docker-compose.dev.yml logs -f; else COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-dev -f docker-compose.dev.yml up; fi\"",
|
"docker:dev:up": "sh -lc \"set -e; echo '[docker] Starting dev environment...'; if docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps -q 2>/dev/null | grep -q .; then echo '[docker] Already running, attaching to logs...'; docker-compose -p gridpilot-dev -f docker-compose.dev.yml logs -f; else COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-dev -f docker-compose.dev.yml up; fi\"",
|
||||||
"docker:e2e:build": "sh -lc \"echo '[e2e] Building website image...'; docker build -f apps/website/Dockerfile.e2e -t gridpilot-website-e2e . && echo '[e2e] Starting full stack...'; docker-compose -f docker-compose.e2e.yml up -d --build\"",
|
"docker:e2e:build": "sh -lc \"echo '[e2e] Building website image...'; docker build -f apps/website/Dockerfile.e2e -t gridpilot-website-e2e . && echo '[e2e] Starting full stack...'; docker-compose -f docker-compose.e2e.yml up -d --build\"",
|
||||||
|
"docker:e2e:clean": "sh -lc \"echo '[e2e] Cleaning up...'; docker-compose -f docker-compose.e2e.yml down -v --remove-orphans; docker rmi gridpilot-website-e2e 2>/dev/null || true; echo '[e2e] Cleanup complete'\"",
|
||||||
"docker:e2e:down": "sh -lc \"echo '[e2e] Stopping e2e environment...'; docker-compose -f docker-compose.e2e.yml down --remove-orphans; echo '[e2e] Stopped'\"",
|
"docker:e2e:down": "sh -lc \"echo '[e2e] Stopping e2e environment...'; docker-compose -f docker-compose.e2e.yml down --remove-orphans; echo '[e2e] Stopped'\"",
|
||||||
|
"docker:e2e:logs": "sh -lc \"docker-compose -f docker-compose.e2e.yml logs -f\"",
|
||||||
|
"docker:e2e:ps": "sh -lc \"docker-compose -f docker-compose.e2e.yml ps\"",
|
||||||
"docker:e2e:up": "sh -lc \"echo '[e2e] Starting full stack...'; docker-compose -f docker-compose.e2e.yml up -d --build\"",
|
"docker:e2e:up": "sh -lc \"echo '[e2e] Starting full stack...'; docker-compose -f docker-compose.e2e.yml up -d --build\"",
|
||||||
"docker:prod": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml up -d",
|
"docker:prod": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml up -d",
|
||||||
"docker:prod:build": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml up -d --build",
|
"docker:prod:build": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml up -d --build",
|
||||||
@@ -113,18 +117,15 @@
|
|||||||
"smoke:website:docker": "npx playwright test -c playwright.website.config.ts",
|
"smoke:website:docker": "npx playwright test -c playwright.website.config.ts",
|
||||||
"test": "vitest run \"$@\"",
|
"test": "vitest run \"$@\"",
|
||||||
"test:api:contracts": "vitest run --config vitest.api.config.ts apps/api/src/shared/testing/contractValidation.test.ts",
|
"test:api:contracts": "vitest run --config vitest.api.config.ts apps/api/src/shared/testing/contractValidation.test.ts",
|
||||||
|
"test:api:smoke": "sh -lc \"echo '🚀 Running API smoke tests...'; npx playwright test tests/e2e/api/api-smoke.test.ts --reporter=json,html\"",
|
||||||
|
"test:api:smoke:docker": "sh -lc \"echo '🚀 Running API smoke tests in Docker...'; docker-compose -f docker-compose.e2e.yml run --rm playwright npx playwright test tests/e2e/api/api-smoke.test.ts --reporter=json,html\"",
|
||||||
"test:companion-hosted": "vitest run --config vitest.e2e.config.ts tests/e2e/companion/companion-ui-full-workflow.e2e.test.ts",
|
"test:companion-hosted": "vitest run --config vitest.e2e.config.ts tests/e2e/companion/companion-ui-full-workflow.e2e.test.ts",
|
||||||
"test:contract:compatibility": "tsx scripts/contract-compatibility.ts",
|
"test:contract:compatibility": "tsx scripts/contract-compatibility.ts",
|
||||||
"test:contracts": "tsx scripts/run-contract-tests.ts",
|
"test:contracts": "tsx scripts/run-contract-tests.ts",
|
||||||
"test:e2e:run": "sh -lc \"npm run docker:e2e:up && echo '[e2e] Running Playwright tests...'; docker-compose -f docker-compose.e2e.yml run --rm playwright npx playwright test\"",
|
|
||||||
"test:e2e:website": "sh -lc \"set -e; trap 'npm run docker:e2e:down' EXIT; npm run docker:e2e:up && echo '[e2e] Waiting for services...'; sleep 10 && echo '[e2e] Running Playwright tests...'; docker-compose -f docker-compose.e2e.yml run --rm playwright\"",
|
|
||||||
"test:api:smoke": "sh -lc \"echo '🚀 Running API smoke tests...'; npx playwright test tests/e2e/api/api-smoke.test.ts --reporter=json,html\"",
|
|
||||||
"test:api:smoke:docker": "sh -lc \"echo '🚀 Running API smoke tests in Docker...'; docker-compose -f docker-compose.e2e.yml run --rm playwright npx playwright test tests/e2e/api/api-smoke.test.ts --reporter=json,html\"",
|
|
||||||
"docker:e2e:logs": "sh -lc \"docker-compose -f docker-compose.e2e.yml logs -f\"",
|
|
||||||
"docker:e2e:ps": "sh -lc \"docker-compose -f docker-compose.e2e.yml ps\"",
|
|
||||||
"docker:e2e:clean": "sh -lc \"echo '[e2e] Cleaning up...'; docker-compose -f docker-compose.e2e.yml down -v --remove-orphans; docker rmi gridpilot-website-e2e 2>/dev/null || true; echo '[e2e] Cleanup complete'\"",
|
|
||||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||||
"test:e2e:docker": "vitest run --config vitest.e2e.config.ts tests/e2e/docker/",
|
"test:e2e:docker": "vitest run --config vitest.e2e.config.ts tests/e2e/docker/",
|
||||||
|
"test:e2e:run": "sh -lc \"npm run docker:e2e:up && echo '[e2e] Running Playwright tests...'; docker-compose -f docker-compose.e2e.yml run --rm playwright npx playwright test\"",
|
||||||
|
"test:e2e:website": "sh -lc \"set -e; trap 'npm run docker:e2e:down' EXIT; npm run docker:e2e:up && echo '[e2e] Waiting for services...'; sleep 10 && echo '[e2e] Running Playwright tests...'; docker-compose -f docker-compose.e2e.yml run --rm playwright\"",
|
||||||
"test:hosted-real": "vitest run --config vitest.e2e.config.ts tests/e2e/hosted-real/",
|
"test:hosted-real": "vitest run --config vitest.e2e.config.ts tests/e2e/hosted-real/",
|
||||||
"test:integration": "vitest run tests/integration",
|
"test:integration": "vitest run tests/integration",
|
||||||
"test:smoke": "vitest run --config vitest.smoke.config.ts",
|
"test:smoke": "vitest run --config vitest.smoke.config.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user