website refactor

This commit is contained in:
2026-01-17 15:46:55 +01:00
parent 4d5ce9bfd6
commit 72a626ce71
346 changed files with 19308 additions and 8605 deletions

View File

@@ -6,7 +6,6 @@ import { routes } from '@/lib/routing/RouteConfig';
import { Card } from '@/ui/Card';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { StatCard } from '@/ui/StatCard';
import { QuickActionLink } from '@/ui/QuickActionLink';
import { StatusBadge } from '@/ui/StatusBadge';
import { Box } from '@/ui/Box';
@@ -14,20 +13,24 @@ import { Stack } from '@/ui/Stack';
import { Container } from '@/ui/Container';
import { Grid } from '@/ui/Grid';
import { Icon } from '@/ui/Icon';
import { Heading } from '@/ui/Heading';
import { AdminHeaderPanel } from '@/components/admin/AdminHeaderPanel';
import { AdminStatsPanel } from '@/components/admin/AdminStatsPanel';
import { AdminSectionHeader } from '@/components/admin/AdminSectionHeader';
import { AdminDangerZonePanel } from '@/components/admin/AdminDangerZonePanel';
import {
Users,
Shield,
Activity,
Clock,
RefreshCw
RefreshCw,
ArrowRight
} from 'lucide-react';
/**
* AdminDashboardTemplate
*
* Pure template for admin dashboard.
* Accepts ViewData only, no business logic.
* Redesigned for "Precision Racing Minimal" theme.
*/
export function AdminDashboardTemplate({
viewData,
@@ -38,113 +41,130 @@ export function AdminDashboardTemplate({
onRefresh: () => void;
isLoading: boolean;
}) {
const stats = [
{
label: 'Total Users',
value: viewData.stats.totalUsers,
icon: Users,
variant: 'blue' as const
},
{
label: 'System Admins',
value: viewData.stats.systemAdmins,
icon: Shield,
variant: 'purple' as const
},
{
label: 'Active Users',
value: viewData.stats.activeUsers,
icon: Activity,
variant: 'green' as const
},
{
label: 'Recent Logins',
value: viewData.stats.recentLogins,
icon: Clock,
variant: 'orange' as const
}
];
return (
<Container size="lg" py={6}>
<Stack gap={6}>
{/* Header */}
<Stack direction="row" align="center" justify="between">
<Box>
<Heading level={1}>Admin Dashboard</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
System overview and statistics
</Text>
</Box>
<Button
onClick={onRefresh}
disabled={isLoading}
variant="secondary"
icon={<Icon icon={RefreshCw} size={4} animate={isLoading ? 'spin' : 'none'} />}
>
Refresh
</Button>
</Stack>
<Container size="lg" py={8}>
<Stack gap={8}>
<AdminHeaderPanel
title="Admin Dashboard"
description="System-wide telemetry and operations control"
isLoading={isLoading}
actions={
<Button
onClick={onRefresh}
disabled={isLoading}
variant="secondary"
size="sm"
icon={<Icon icon={RefreshCw} size={3} animate={isLoading ? 'spin' : 'none'} />}
>
Refresh Telemetry
</Button>
}
/>
{/* Stats Cards */}
<Grid cols={4} gap={4}>
<StatCard
label="Total Users"
value={viewData.stats.totalUsers}
icon={Users}
variant="blue"
/>
<StatCard
label="Admins"
value={viewData.stats.systemAdmins}
icon={Shield}
variant="purple"
/>
<StatCard
label="Active Users"
value={viewData.stats.activeUsers}
icon={Activity}
variant="green"
/>
<StatCard
label="Recent Logins"
value={viewData.stats.recentLogins}
icon={Clock}
variant="orange"
/>
</Grid>
<AdminStatsPanel stats={stats} />
{/* System Status */}
<Card>
<Stack gap={4}>
<Heading level={3}>System Status</Heading>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Text size="sm" color="text-gray-400">
System Health
</Text>
<StatusBadge variant="success">
Healthy
</StatusBadge>
</Stack>
<Stack direction="row" align="center" justify="between">
<Text size="sm" color="text-gray-400">
Suspended Users
</Text>
<Text size="base" weight="medium" color="text-white">
{viewData.stats.suspendedUsers}
</Text>
</Stack>
<Stack direction="row" align="center" justify="between">
<Text size="sm" color="text-gray-400">
Deleted Users
</Text>
<Text size="base" weight="medium" color="text-white">
{viewData.stats.deletedUsers}
</Text>
</Stack>
<Stack direction="row" align="center" justify="between">
<Text size="sm" color="text-gray-400">
New Users Today
</Text>
<Text size="base" weight="medium" color="text-white">
{viewData.stats.newUsersToday}
</Text>
<Grid cols={1} mdCols={2} gap={6}>
{/* System Health & Status */}
<Card p={6}>
<Stack gap={6}>
<AdminSectionHeader
title="System Status"
actions={
<StatusBadge variant="success" icon={Activity}>
Operational
</StatusBadge>
}
/>
<Stack gap={4}>
<Box borderTop borderColor="border-gray" opacity={0.3} />
<Box pt={0}>
<Stack direction="row" align="center" justify="between" py={2}>
<Text size="sm" color="text-gray-400">Suspended Users</Text>
<Text weight="bold" color="text-warning-amber">{viewData.stats.suspendedUsers}</Text>
</Stack>
</Box>
<Box borderTop borderColor="border-gray" opacity={0.3} />
<Box>
<Stack direction="row" align="center" justify="between" py={2}>
<Text size="sm" color="text-gray-400">Deleted Users</Text>
<Text weight="bold" color="text-error-red">{viewData.stats.deletedUsers}</Text>
</Stack>
</Box>
<Box borderTop borderColor="border-gray" opacity={0.3} />
<Box>
<Stack direction="row" align="center" justify="between" py={2}>
<Text size="sm" color="text-gray-400">New Registrations (24h)</Text>
<Text weight="bold" color="text-primary-blue">{viewData.stats.newUsersToday}</Text>
</Stack>
</Box>
</Stack>
</Stack>
</Stack>
</Card>
</Card>
{/* Quick Actions */}
<Card>
<Stack gap={4}>
<Heading level={3}>Quick Actions</Heading>
<Grid cols={3} gap={3}>
<QuickActionLink href={routes.admin.users} variant="blue">
View All Users
</QuickActionLink>
<QuickActionLink href="/admin" variant="purple">
Manage Admins
</QuickActionLink>
<QuickActionLink href="/admin" variant="orange">
View Audit Log
</QuickActionLink>
</Grid>
</Stack>
</Card>
{/* Quick Operations */}
<Card p={6}>
<Stack gap={6}>
<AdminSectionHeader title="Quick Operations" />
<Grid cols={1} gap={3}>
<QuickActionLink href={routes.admin.users} variant="blue">
<Stack direction="row" align="center" justify="between" fullWidth>
<Text size="sm" weight="bold">User Management</Text>
<Icon icon={ArrowRight} size={4} />
</Stack>
</QuickActionLink>
<QuickActionLink href="/admin" variant="purple">
<Stack direction="row" align="center" justify="between" fullWidth>
<Text size="sm" weight="bold">Security & Roles</Text>
<Icon icon={ArrowRight} size={4} />
</Stack>
</QuickActionLink>
<QuickActionLink href="/admin" variant="orange">
<Stack direction="row" align="center" justify="between" fullWidth>
<Text size="sm" weight="bold">System Audit Logs</Text>
<Icon icon={ArrowRight} size={4} />
</Stack>
</QuickActionLink>
</Grid>
</Stack>
</Card>
</Grid>
<AdminDangerZonePanel
title="System Maintenance"
description="Perform destructive system-wide operations. Use with extreme caution."
>
<Button variant="danger" size="sm">
Enter Maintenance Mode
</Button>
</AdminDangerZonePanel>
</Stack>
</Container>
);

View File

@@ -1,28 +1,20 @@
'use client';
import React from 'react';
import { Card } from '@/ui/Card';
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
import { Button } from '@/ui/Button';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Container } from '@/ui/Container';
import { Icon } from '@/ui/Icon';
import { StatusBadge } from '@/ui/StatusBadge';
import { InfoBox } from '@/ui/InfoBox';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import {
RefreshCw,
Shield,
Trash2,
Users
} from 'lucide-react';
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { RefreshCw, Users, ShieldAlert } from 'lucide-react';
import { AdminHeaderPanel } from '@/components/admin/AdminHeaderPanel';
import { AdminStatsPanel } from '@/components/admin/AdminStatsPanel';
import { UserFilters } from '@/components/admin/UserFilters';
import { UserStatsSummary } from '@/components/admin/UserStatsSummary';
import { Surface } from '@/ui/Surface';
import { AdminUsersTable } from '@/components/admin/AdminUsersTable';
import { BulkActionBar } from '@/components/admin/BulkActionBar';
import { InlineNotice } from '@/components/shared/ux/InlineNotice';
import { AdminDataTable } from '@/components/admin/AdminDataTable';
import { AdminEmptyState } from '@/components/admin/AdminEmptyState';
interface AdminUsersTemplateProps {
viewData: AdminUsersViewData;
@@ -39,8 +31,20 @@ interface AdminUsersTemplateProps {
loading: boolean;
error: string | null;
deletingUser: string | null;
// Selection state passed from wrapper
selectedUserIds: string[];
onSelectUser: (userId: string) => void;
onSelectAll: () => void;
onClearSelection: () => void;
}
/**
* AdminUsersTemplate
*
* Redesigned user management page.
* Uses semantic admin UI blocks and follows "Precision Racing Minimal" theme.
* Stateless template.
*/
export function AdminUsersTemplate({
viewData,
onRefresh,
@@ -55,56 +59,81 @@ export function AdminUsersTemplate({
statusFilter,
loading,
error,
deletingUser
deletingUser,
selectedUserIds,
onSelectUser,
onSelectAll,
onClearSelection
}: AdminUsersTemplateProps) {
const getStatusBadgeVariant = (status: string): 'success' | 'warning' | 'error' | 'info' => {
switch (status) {
case 'active': return 'success';
case 'suspended': return 'warning';
case 'deleted': return 'error';
default: return 'info';
const stats = [
{
label: 'Total Users',
value: viewData.total,
icon: Users,
variant: 'blue' as const
},
{
label: 'Active Users',
value: viewData.activeUserCount,
icon: RefreshCw,
variant: 'green' as const
},
{
label: 'System Admins',
value: viewData.adminCount,
icon: ShieldAlert,
variant: 'purple' as const
}
};
];
const getRoleBadgeProps = (role: string): { bg: string; color: string; borderColor: string } => {
switch (role) {
case 'owner': return { bg: 'bg-purple-500/20', color: '#d8b4fe', borderColor: 'border-purple-500/30' };
case 'admin': return { bg: 'bg-blue-500/20', color: '#93c5fd', borderColor: 'border-blue-500/30' };
default: return { bg: 'bg-neutral-500/20', color: '#d1d5db', borderColor: 'border-neutral-500/30' };
const bulkActions = [
{
label: 'Suspend Selected',
onClick: () => {
console.log('Bulk suspend', selectedUserIds);
},
variant: 'secondary' as const
},
{
label: 'Delete Selected',
onClick: () => {
console.log('Bulk delete', selectedUserIds);
},
variant: 'danger' as const
}
};
];
return (
<Container size="lg" py={6}>
<Stack gap={6}>
{/* Header */}
<Stack direction="row" align="center" justify="between">
<Box>
<Heading level={1}>User Management</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>Manage and monitor all system users</Text>
</Box>
<Button
onClick={onRefresh}
disabled={loading}
variant="secondary"
icon={<Icon icon={RefreshCw} size={4} animate={loading ? 'spin' : 'none'} />}
>
Refresh
</Button>
</Stack>
<Container size="lg" py={8}>
<Stack gap={8}>
<AdminHeaderPanel
title="User Management"
description="Monitor and control system access"
isLoading={loading}
actions={
<Button
onClick={onRefresh}
disabled={loading}
variant="secondary"
size="sm"
icon={<Icon icon={RefreshCw} size={3} animate={loading ? 'spin' : 'none'} />}
>
Refresh Data
</Button>
}
/>
{/* Error Banner */}
{error && (
<InfoBox
icon={Users}
title="Error"
description={error}
variant="warning"
<InlineNotice
variant="error"
title="Operation Failed"
message={error}
/>
)}
{/* Filters Card */}
<UserFilters
<AdminStatsPanel stats={stats} />
<UserFilters
search={search}
roleFilter={roleFilter}
statusFilter={statusFilter}
@@ -114,137 +143,36 @@ export function AdminUsersTemplate({
onClearFilters={onClearFilters}
/>
{/* Users Table */}
<Card p={0}>
{loading ? (
<Stack center py={12} gap={3}>
<Box animate="spin" rounded="full" h="2rem" w="2rem" borderBottom borderColor="border-primary-blue" />
<Text color="text-gray-400">Loading users...</Text>
</Stack>
) : !viewData.users || viewData.users.length === 0 ? (
<Stack center py={12} gap={3}>
<Icon icon={Users} size={12} color="#525252" />
<Text color="text-gray-400">No users found</Text>
<Button
onClick={onClearFilters}
variant="ghost"
size="sm"
>
Clear filters
</Button>
</Stack>
<AdminDataTable>
{viewData.users.length === 0 && !loading ? (
<AdminEmptyState
icon={Users}
title="No users found"
description="Try adjusting your filters or search query"
action={
<Button variant="secondary" size="sm" onClick={onClearFilters}>
Clear All Filters
</Button>
}
/>
) : (
<Table>
<TableHead>
<TableRow>
<TableHeader>User</TableHeader>
<TableHeader>Email</TableHeader>
<TableHeader>Roles</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader>Last Login</TableHeader>
<TableHeader>Actions</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{viewData.users.map((user) => (
<TableRow key={user.id}>
<TableCell>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="full" padding={2} bg="bg-primary-blue/20">
<Icon icon={Shield} size={4} color="#3b82f6" />
</Surface>
<Box>
<Text weight="medium" color="text-white" block>{user.displayName}</Text>
<Text size="xs" color="text-gray-500" block>ID: {user.id}</Text>
{user.primaryDriverId && (
<Text size="xs" color="text-gray-500" block>Driver: {user.primaryDriverId}</Text>
)}
</Box>
</Stack>
</TableCell>
<TableCell>
<Text size="sm" color="text-gray-300">{user.email}</Text>
</TableCell>
<TableCell>
<Stack direction="row" gap={1} wrap>
{user.roles.map((role, idx) => {
const badgeProps = getRoleBadgeProps(role);
return (
<Surface
key={idx}
variant="muted"
rounded="full"
padding={1}
px={2}
bg={badgeProps.bg}
color={badgeProps.color}
borderColor={badgeProps.borderColor}
border
>
<Text size="xs" weight="medium">{role.charAt(0).toUpperCase() + role.slice(1)}</Text>
</Surface>
);
})}
</Stack>
</TableCell>
<TableCell>
<StatusBadge variant={getStatusBadgeVariant(user.status)}>
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
</StatusBadge>
</TableCell>
<TableCell>
<Text size="sm" color="text-gray-400">
{user.lastLoginAt ? DateDisplay.formatShort(user.lastLoginAt) : 'Never'}
</Text>
</TableCell>
<TableCell>
<Stack direction="row" align="center" gap={2}>
{user.status === 'active' && (
<Button
onClick={() => onUpdateStatus(user.id, 'suspended')}
variant="secondary"
size="sm"
>
Suspend
</Button>
)}
{user.status === 'suspended' && (
<Button
onClick={() => onUpdateStatus(user.id, 'active')}
variant="secondary"
size="sm"
>
Activate
</Button>
)}
{user.status !== 'deleted' && (
<Button
onClick={() => onDeleteUser(user.id)}
disabled={deletingUser === user.id}
variant="secondary"
size="sm"
icon={<Icon icon={Trash2} size={3} />}
>
{deletingUser === user.id ? 'Deleting...' : 'Delete'}
</Button>
)}
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<AdminUsersTable
users={viewData.users}
selectedUserIds={selectedUserIds}
onSelectUser={onSelectUser}
onSelectAll={onSelectAll}
onUpdateStatus={onUpdateStatus}
onDeleteUser={onDeleteUser}
deletingUserId={deletingUser}
/>
)}
</Card>
</AdminDataTable>
{/* Stats Summary */}
{viewData.users.length > 0 && (
<UserStatsSummary
total={viewData.total}
activeCount={viewData.activeUserCount}
adminCount={viewData.adminCount}
/>
)}
<BulkActionBar
selectedCount={selectedUserIds.length}
actions={bulkActions}
onClearSelection={onClearSelection}
/>
</Stack>
</Container>
);

View File

@@ -2,64 +2,211 @@
import React from 'react';
import type { DashboardViewData } from '@/lib/view-data/DashboardViewData';
import { DashboardShell } from '@/components/dashboard/DashboardShell';
import { DashboardRail } from '@/components/dashboard/DashboardRail';
import { DashboardControlBar } from '@/components/dashboard/DashboardControlBar';
import { TelemetryPanel } from '@/components/dashboard/TelemetryPanel';
import { DashboardKpiRow } from '@/components/dashboard/DashboardKpiRow';
import { RecentActivityTable, type ActivityItem } from '@/components/dashboard/RecentActivityTable';
import { LayoutDashboard, Trophy, Calendar, Users, Settings, Bell, Search } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import { Box } from '@/ui/Box';
import { Container } from '@/ui/Container';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { DashboardHero } from '@/components/dashboard/DashboardHeroWrapper';
import { NextRaceCard } from '@/components/races/NextRaceCardWrapper';
import { ChampionshipStandings } from '@/components/leagues/ChampionshipStandings';
import { ActivityFeed } from '@/components/feed/ActivityFeed';
import { UpcomingRaces } from '@/components/races/UpcomingRaces';
import { FriendsSidebar } from '@/components/social/FriendsSidebar';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { IconButton } from '@/ui/IconButton';
import { Avatar } from '@/ui/Avatar';
import { Grid } from '@/ui/Grid';
import { Button } from '@/ui/Button';
interface DashboardTemplateProps {
viewData: DashboardViewData;
}
/**
* DashboardTemplate
*
* Redesigned as a "Telemetry Workspace" following the Precision Racing Minimal theme.
* Composes semantic dashboard components into a high-density data environment.
* Complies with architectural constraints by using UI primitives.
*/
export function DashboardTemplate({ viewData }: DashboardTemplateProps) {
const router = useRouter();
const {
currentDriver,
nextRace,
upcomingRaces,
leagueStandings,
feedItems,
friends,
activeLeaguesCount,
hasUpcomingRaces,
hasLeagueStandings,
hasFeedItems,
hasFriends,
} = viewData;
return (
<Box as="main">
<DashboardHero
currentDriver={currentDriver}
activeLeaguesCount={activeLeaguesCount}
const kpiItems = [
{ label: 'Rating', value: currentDriver.rating, color: 'var(--color-telemetry)' },
{ label: 'Rank', value: `#${currentDriver.rank}`, color: 'var(--color-warning)' },
{ label: 'Starts', value: currentDriver.totalRaces },
{ label: 'Wins', value: currentDriver.wins, color: 'var(--color-success)' },
{ label: 'Podiums', value: currentDriver.podiums, color: 'var(--color-warning)' },
{ label: 'Leagues', value: activeLeaguesCount },
];
const activityItems: ActivityItem[] = feedItems.map(item => ({
id: item.id,
type: item.type.toUpperCase(),
description: item.headline,
timestamp: item.formattedTime,
status: item.type === 'race_result' ? 'success' : 'info'
}));
const railContent = (
<DashboardRail>
<Stack direction="col" align="center" gap={6} fullWidth>
<Box h="8" w="8" rounded="sm" bg="primary-accent" display="flex" alignItems="center" justifyContent="center">
<Text size="xs" weight="bold">GP</Text>
</Box>
<IconButton
icon={LayoutDashboard}
onClick={() => router.push(routes.protected.dashboard)}
variant="ghost"
color="primary-accent"
/>
<IconButton
icon={Trophy}
onClick={() => router.push(routes.public.leagues)}
variant="ghost"
color="var(--color-text-low)"
/>
<IconButton
icon={Calendar}
onClick={() => router.push(routes.public.races)}
variant="ghost"
color="var(--color-text-low)"
/>
<IconButton
icon={Users}
onClick={() => router.push(routes.public.teams)}
variant="ghost"
color="var(--color-text-low)"
/>
</Stack>
<Box mt="auto" display="flex" flexDirection="col" alignItems="center" gap={6} pb={4}>
<IconButton
icon={Settings}
onClick={() => router.push(routes.protected.profile)}
variant="ghost"
color="var(--color-text-low)"
/>
</Box>
</DashboardRail>
);
const controlBarActions = (
<Stack direction="row" align="center" gap={4}>
<IconButton icon={Search} variant="ghost" color="var(--color-text-low)" />
<Box position="relative">
<IconButton icon={Bell} variant="ghost" color="var(--color-text-low)" />
<Box position="absolute" top="0" right="0" h="1.5" w="1.5" rounded="full" bg="critical-red" />
</Box>
<Avatar
src={currentDriver.avatarUrl}
alt={currentDriver.name}
size={32}
/>
</Stack>
);
<Container size="lg" py={8}>
<Grid cols={12} gap={6}>
{/* Left Column - Main Content */}
<GridItem colSpan={12} lgSpan={8}>
<Stack gap={6}>
{nextRace && <NextRaceCard nextRace={nextRace} />}
{hasLeagueStandings && <ChampionshipStandings standings={leagueStandings} />}
<ActivityFeed items={feedItems} hasItems={hasFeedItems} />
</Stack>
</GridItem>
return (
<DashboardShell
rail={railContent}
controlBar={<DashboardControlBar title="Telemetry Workspace" actions={controlBarActions} />}
>
{/* KPI Overview */}
<DashboardKpiRow items={kpiItems} />
{/* Right Column - Sidebar */}
<GridItem colSpan={12} lgSpan={4}>
<Stack gap={6}>
<UpcomingRaces races={upcomingRaces} hasRaces={hasUpcomingRaces} />
<FriendsSidebar friends={friends} hasFriends={hasFriends} />
</Stack>
</GridItem>
</Grid>
</Container>
</Box>
<Grid responsiveGridCols={{ base: 1, lg: 12 }} gap={6}>
{/* Main Content Column */}
<Box responsiveColSpan={{ base: 1, lg: 8 }}>
<Stack direction="col" gap={6}>
{nextRace && (
<TelemetryPanel title="Active Session">
<Box display="flex" alignItems="center" justifyContent="between">
<Box>
<Text size="xs" color="var(--color-text-low)" mb={1} block>Next Event</Text>
<Text size="lg" weight="bold" block>{nextRace.track}</Text>
<Text size="xs" color="var(--color-telemetry)" font="mono" block>{nextRace.car}</Text>
</Box>
<Box textAlign="right">
<Text size="xs" color="var(--color-text-low)" mb={1} block>Starts In</Text>
<Text size="xl" font="mono" weight="bold" color="var(--color-warning)" block>{nextRace.timeUntil}</Text>
<Text size="xs" color="var(--color-text-low)" block>{nextRace.formattedDate} @ {nextRace.formattedTime}</Text>
</Box>
</Box>
</TelemetryPanel>
)}
<TelemetryPanel title="Recent Activity">
{hasFeedItems ? (
<RecentActivityTable items={activityItems} />
) : (
<Box py={8} textAlign="center">
<Text italic color="var(--color-text-low)">No recent activity recorded.</Text>
</Box>
)}
</TelemetryPanel>
</Stack>
</Box>
{/* Sidebar Column */}
<Box responsiveColSpan={{ base: 1, lg: 4 }}>
<Stack direction="col" gap={6}>
<TelemetryPanel title="Championship Standings">
{hasLeagueStandings ? (
<Stack direction="col" gap={3}>
{leagueStandings.map((standing) => (
<Box key={standing.leagueId} display="flex" alignItems="center" justifyContent="between" borderBottom borderColor="rgba(35, 39, 43, 0.3)" pb={2}>
<Box>
<Text size="xs" weight="bold" truncate block maxWidth="180px">{standing.leagueName}</Text>
<Text size="xs" color="var(--color-text-low)" block>Pos: {standing.position} / {standing.totalDrivers}</Text>
</Box>
<Text size="sm" font="mono" weight="bold" color="var(--color-telemetry)">{standing.points} PTS</Text>
</Box>
))}
</Stack>
) : (
<Box py={4} textAlign="center">
<Text italic color="var(--color-text-low)">No active championships.</Text>
</Box>
)}
</TelemetryPanel>
<TelemetryPanel title="Upcoming Schedule">
<Stack direction="col" gap={4}>
{upcomingRaces.slice(0, 3).map((race) => (
<Box key={race.id} group cursor="pointer">
<Box display="flex" justifyContent="between" alignItems="start" mb={1}>
<Text size="xs" weight="bold" groupHoverTextColor="var(--color-primary)" transition>{race.track}</Text>
<Text size="xs" font="mono" color="var(--color-text-low)">{race.timeUntil}</Text>
</Box>
<Box display="flex" justifyContent="between">
<Text size="xs" color="var(--color-text-low)">{race.car}</Text>
<Text size="xs" color="var(--color-text-low)">{race.formattedDate}</Text>
</Box>
</Box>
))}
<Button
variant="secondary"
fullWidth
onClick={() => router.push(routes.public.races)}
>
<Text size="xs" weight="bold" uppercase letterSpacing="widest">View Full Schedule</Text>
</Button>
</Stack>
</TelemetryPanel>
</Stack>
</Box>
</Grid>
</DashboardShell>
);
}

View File

@@ -1,26 +1,26 @@
'use client';
import { RatingBreakdown } from '@/ui/RatingBreakdown';
import { Breadcrumbs } from '@/ui/Breadcrumbs';
import { AchievementGrid } from '@/components/achievements/AchievementGrid';
import { CareerStats } from '@/ui/CareerStats';
import { FriendsPreview } from '@/components/social/FriendsPreview';
import { PerformanceOverview } from '@/ui/PerformanceOverview';
import { ProfileBio } from '@/ui/ProfileBio';
import { ProfileHero } from '@/components/drivers/ProfileHero';
import { ProfileTabs, type ProfileTab } from '@/ui/ProfileTabs';
import { RacingProfile } from '@/ui/RacingProfile';
import { TeamMembershipGrid } from '@/ui/TeamMembershipGrid';
import React from 'react';
import { ArrowLeft } from 'lucide-react';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Container } from '@/ui/Container';
import { Breadcrumbs } from '@/ui/Breadcrumbs';
import { LoadingSpinner } from '@/ui/LoadingSpinner';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import {
ArrowLeft,
} from 'lucide-react';
import React from 'react';
import { AchievementGrid } from '@/components/achievements/AchievementGrid';
import { FriendsPreview } from '@/components/social/FriendsPreview';
import { TeamMembershipGrid } from '@/ui/TeamMembershipGrid';
import { RatingBreakdown } from '@/ui/RatingBreakdown';
import { DriverProfileHeader } from '@/components/drivers/DriverProfileHeader';
import { DriverStatsPanel } from '@/components/drivers/DriverStatsPanel';
import { DriverProfileTabs, type ProfileTab } from '@/components/drivers/DriverProfileTabs';
import { DriverPerformanceOverview } from '@/components/drivers/DriverPerformanceOverview';
import { DriverRacingProfile } from '@/components/drivers/DriverRacingProfile';
import { CareerStats } from '@/ui/CareerStats';
import type { DriverProfileViewData } from '@/lib/types/view-data/DriverProfileViewData';
interface DriverProfileTemplateProps {
@@ -74,48 +74,57 @@ export function DriverProfileTemplate({
const { currentDriver, stats, teamMemberships, socialSummary, extendedProfile } = viewData;
const careerStats = stats ? [
{ label: 'Rating', value: stats.rating || 0, color: 'text-primary-blue' },
{ label: 'Wins', value: stats.wins, color: 'text-performance-green' },
{ label: 'Podiums', value: stats.podiums, color: 'text-warning-amber' },
{ label: 'Total Races', value: stats.totalRaces },
{ label: 'Avg Finish', value: stats.avgFinish?.toFixed(1) || '-', subValue: 'POS' },
{ label: 'Consistency', value: stats.consistency ? `${stats.consistency}%` : '-' },
] : [];
return (
<Container size="lg" py={8}>
<Stack gap={6}>
{/* Back Navigation */}
<Box>
<Button
variant="secondary"
onClick={onBackClick}
icon={<ArrowLeft size={4} />}
>
Back to Drivers
</Button>
</Box>
{/* Breadcrumb */}
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Drivers', href: '/drivers' },
{ label: currentDriver.name },
]}
/>
{/* Back Navigation & Breadcrumbs */}
<Stack gap={4}>
<Box display="flex" alignItems="center" justifyContent="between">
<Button
variant="secondary"
onClick={onBackClick}
icon={<ArrowLeft size={16} />}
>
Back to Drivers
</Button>
</Box>
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Drivers', href: '/drivers' },
{ label: currentDriver.name },
]}
/>
</Stack>
{/* Sponsor Insights Card */}
{isSponsorMode && sponsorInsights}
{/* Hero Header Section */}
<ProfileHero
driver={{
...currentDriver,
iracingId: currentDriver.iracingId || 0,
}}
stats={stats ? { rating: stats.rating || 0 } : null}
globalRank={currentDriver.globalRank || 0}
timezone={extendedProfile?.timezone || 'UTC'}
socialHandles={extendedProfile?.socialHandles || []}
onAddFriend={onAddFriend}
<DriverProfileHeader
name={currentDriver.name}
avatarUrl={currentDriver.avatarUrl}
nationality={currentDriver.country}
rating={stats?.rating || 0}
globalRank={currentDriver.globalRank ?? undefined}
bio={currentDriver.bio}
friendRequestSent={friendRequestSent}
onAddFriend={onAddFriend}
/>
{/* Bio Section */}
{currentDriver.bio && <ProfileBio bio={currentDriver.bio} />}
{/* Stats Grid */}
{careerStats.length > 0 && (
<DriverStatsPanel stats={careerStats} />
)}
{/* Team Memberships */}
{teamMemberships.length > 0 && (
@@ -128,31 +137,28 @@ export function DriverProfileTemplate({
/>
)}
{/* Performance Overview */}
{stats && (
<PerformanceOverview
stats={{
wins: stats.wins,
podiums: stats.podiums,
totalRaces: stats.totalRaces,
consistency: stats.consistency,
dnfs: stats.dnfs,
bestFinish: stats.bestFinish || 0,
avgFinish: stats.avgFinish
}}
/>
)}
{/* Tab Navigation */}
<ProfileTabs activeTab={activeTab} onTabChange={onTabChange} />
<DriverProfileTabs activeTab={activeTab} onTabChange={onTabChange} />
{/* Tab Content */}
{activeTab === 'overview' && (
<Stack gap={6}>
<CareerStats stats={stats || { totalRaces: 0, wins: 0, podiums: 0, consistency: 0 }} />
{stats && (
<DriverPerformanceOverview
stats={{
wins: stats.wins,
podiums: stats.podiums,
totalRaces: stats.totalRaces,
consistency: stats.consistency || 0,
dnfs: stats.dnfs,
bestFinish: stats.bestFinish || 0,
avgFinish: stats.avgFinish || 0
}}
/>
)}
{extendedProfile && (
<RacingProfile
<DriverRacingProfile
racingStyle={extendedProfile.racingStyle}
favoriteTrack={extendedProfile.favoriteTrack}
favoriteCar={extendedProfile.favoriteCar}
@@ -177,10 +183,21 @@ export function DriverProfileTemplate({
</Stack>
)}
{activeTab === 'stats' && !stats && (
<Stack align="center" py={12} gap={4}>
<Text color="text-gray-400">No statistics available yet</Text>
<Text size="sm" color="text-gray-500">This driver hasn&apos;t completed any races yet</Text>
{activeTab === 'stats' && (
<Stack gap={6}>
{stats ? (
<CareerStats stats={{
totalRaces: stats.totalRaces,
wins: stats.wins,
podiums: stats.podiums,
consistency: stats.consistency
}} />
) : (
<Box display="flex" flexDirection="col" alignItems="center" justifyContent="center" py={12} gap={4} rounded="2xl" border borderColor="border-charcoal-outline" bg="bg-deep-charcoal/30">
<Text color="text-gray-400">No statistics available yet</Text>
<Text size="sm" color="text-gray-500">This driver hasn&apos;t completed any races yet</Text>
</Box>
)}
</Stack>
)}

View File

@@ -1,81 +1,67 @@
'use client';
import React from 'react';
import { Trophy, ArrowLeft } from 'lucide-react';
import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Trophy } from 'lucide-react';
import { Container } from '@/ui/Container';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
import type { DriverRankingsViewData } from '@/lib/view-data/DriverRankingsViewData';
import { RankingsPodium } from '@/ui/RankingsPodium';
import { RankingsTable } from '@/ui/RankingsTable';
import { LeaderboardHeader } from '@/components/leaderboards/LeaderboardHeader';
import { LeaderboardFiltersBar } from '@/components/leaderboards/LeaderboardFiltersBar';
import { LeaderboardPodium } from '@/components/leaderboards/LeaderboardPodium';
import { LeaderboardTable } from '@/components/leaderboards/LeaderboardTable';
interface DriverRankingsTemplateProps {
viewData: DriverRankingsViewData;
searchQuery: string;
onSearchChange: (query: string) => void;
onDriverClick?: (id: string) => void;
onBackToLeaderboards?: () => void;
}
export function DriverRankingsTemplate({
viewData,
searchQuery,
onSearchChange,
onDriverClick,
onBackToLeaderboards,
}: DriverRankingsTemplateProps): React.ReactElement {
return (
<Container size="lg" py={8}>
<Stack gap={8}>
{/* Header */}
<Box>
{onBackToLeaderboards && (
<Box mb={6}>
<Button
variant="secondary"
onClick={onBackToLeaderboards}
icon={<Icon icon={ArrowLeft} size={4} />}
>
Back to Leaderboards
</Button>
</Box>
)}
<LeaderboardHeader
title="Driver Leaderboard"
description="Full rankings of all drivers by performance metrics"
icon={Trophy}
onBack={onBackToLeaderboards}
backLabel="Back to Leaderboards"
/>
<Stack direction="row" align="center" gap={4}>
<Surface variant="muted" rounded="xl" padding={3} bg="linear-gradient(to bottom right, rgba(59, 130, 246, 0.2), rgba(59, 130, 246, 0.05))" border borderColor="border-blue-500/20">
<Icon icon={Trophy} size={7} color="#3b82f6" />
</Surface>
<Box>
<Heading level={1}>Driver Leaderboard</Heading>
<Text color="text-gray-400" block mt={1}>Full rankings of all drivers by performance metrics</Text>
</Box>
</Stack>
</Box>
{/* Top 3 Podium */}
{viewData.podium.length > 0 && (
<RankingsPodium
podium={viewData.podium.map(d => ({
...d,
rating: Number(d.rating),
wins: Number(d.wins),
podiums: Number(d.podiums)
}))}
onDriverClick={onDriverClick}
/>
)}
{/* Leaderboard Table */}
<RankingsTable
drivers={viewData.drivers.map(d => ({
{/* Top 3 Podium */}
{viewData.podium.length > 0 && !searchQuery && (
<LeaderboardPodium
podium={viewData.podium.map(d => ({
...d,
rating: Number(d.rating),
wins: Number(d.wins)
wins: Number(d.wins),
podiums: Number(d.podiums)
}))}
onDriverClick={onDriverClick}
/>
</Stack>
)}
<LeaderboardFiltersBar
searchQuery={searchQuery}
onSearchChange={onSearchChange}
placeholder="Search drivers..."
/>
{/* Leaderboard Table */}
<LeaderboardTable
drivers={viewData.drivers.map(d => ({
...d,
rating: Number(d.rating),
wins: Number(d.wins)
}))}
onDriverClick={onDriverClick}
/>
</Container>
);
}

View File

@@ -1,30 +1,14 @@
'use client';
import React from 'react';
import {
Search,
Crown,
Users,
Trophy,
} from 'lucide-react';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Search } from 'lucide-react';
import { Container } from '@/ui/Container';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { FeaturedDriverCard } from '@/components/drivers/FeaturedDriverCard';
import { SkillDistribution } from '@/ui/SkillDistribution';
import { CategoryDistribution } from '@/ui/CategoryDistribution';
import { LeaderboardPreview } from '@/components/leaderboards/LeaderboardPreview';
import { RecentActivity } from '@/components/feed/RecentActivity';
import { PageHero } from '@/ui/PageHero';
import { DriversSearch } from '@/ui/DriversSearch';
import { Stack } from '@/ui/Stack';
import { DriversDirectoryHeader } from '@/components/drivers/DriversDirectoryHeader';
import { DriverSearchBar } from '@/components/drivers/DriverSearchBar';
import { DriverTable } from '@/components/drivers/DriverTable';
import { DriverTableRow } from '@/components/drivers/DriverTableRow';
import { EmptyState } from '@/components/shared/state/EmptyState';
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
import type { DriversViewData } from '@/lib/types/view-data/DriversViewData';
interface DriversTemplateProps {
@@ -49,80 +33,34 @@ export function DriversTemplate({
const totalWins = viewData?.totalWins || 0;
const activeCount = viewData?.activeCount || 0;
// Featured drivers (top 4)
const featuredDrivers = filteredDrivers.slice(0, 4);
return (
<Container size="lg" py={8}>
<Stack gap={10}>
{/* Hero Section */}
<PageHero
title="Drivers"
description="Meet the racers who make every lap count. From rookies to champions, track their journey and see who's dominating the grid."
icon={Users}
stats={[
{ label: 'drivers', value: drivers.length, color: 'text-primary-blue' },
{ label: 'active', value: activeCount, color: 'text-performance-green', animate: true },
{ label: 'total wins', value: NumberDisplay.format(totalWins), color: 'text-warning-amber' },
{ label: 'races', value: NumberDisplay.format(totalRaces), color: 'text-neon-aqua' },
]}
actions={[
{
label: 'View Leaderboard',
onClick: onViewLeaderboard,
icon: Trophy,
description: 'See full driver rankings'
}
]}
<DriversDirectoryHeader
totalDrivers={drivers.length}
activeDrivers={activeCount}
totalWins={totalWins}
totalRaces={totalRaces}
onViewLeaderboard={onViewLeaderboard}
/>
{/* Search */}
<DriversSearch query={searchQuery} onChange={onSearchChange} />
<DriverSearchBar query={searchQuery} onChange={onSearchChange} />
{/* Featured Drivers */}
{!searchQuery && (
<Box>
<Stack direction="row" align="center" gap={3} mb={4}>
<Surface variant="muted" rounded="xl" padding={2}>
<Icon icon={Crown} size={6} color="#f59e0b" />
</Surface>
<Box>
<Heading level={2}>Featured Drivers</Heading>
<Text size="xs" color="text-gray-500">Top performers on the grid</Text>
</Box>
</Stack>
<DriverTable>
{filteredDrivers.map((driver, index) => (
<DriverTableRow
key={driver.id}
rank={index + 1}
name={driver.name}
avatarUrl={driver.avatarUrl}
nationality={driver.nationality}
rating={driver.rating}
wins={driver.wins}
onClick={() => onDriverClick(driver.id)}
/>
))}
</DriverTable>
<Grid cols={4} gap={4}>
{featuredDrivers.map((driver, index) => (
<GridItem key={driver.id} colSpan={12} mdSpan={6} lgSpan={3}>
<FeaturedDriverCard
driver={driver}
position={index + 1}
onClick={() => onDriverClick(driver.id)}
/>
</GridItem>
))}
</Grid>
</Box>
)}
{/* Active Drivers */}
{!searchQuery && <RecentActivity drivers={drivers} onDriverClick={onDriverClick} />}
{/* Skill Distribution */}
{!searchQuery && <SkillDistribution drivers={drivers} />}
{/* Category Distribution */}
{!searchQuery && <CategoryDistribution drivers={drivers} />}
{/* Leaderboard Preview */}
<LeaderboardPreview
drivers={filteredDrivers}
onDriverClick={onDriverClick}
onNavigate={() => onViewLeaderboard()}
/>
{/* Empty State */}
{filteredDrivers.length === 0 && (
<EmptyState
icon={Search}

View File

@@ -0,0 +1,38 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { FatalErrorTemplate, type FatalErrorViewData } from './FatalErrorTemplate';
describe('FatalErrorTemplate', () => {
const mockError = new Error('Fatal system error');
const mockViewData: FatalErrorViewData = {
error: mockError
};
const mockReset = vi.fn();
const mockOnHome = vi.fn();
it('renders the error message via ErrorScreen', () => {
render(<FatalErrorTemplate viewData={mockViewData} reset={mockReset} onHome={mockOnHome} />);
expect(screen.getByText('Fatal system error')).toBeDefined();
expect(screen.getByText('System Malfunction')).toBeDefined();
});
it('calls reset when Retry Session is clicked', () => {
render(<FatalErrorTemplate viewData={mockViewData} reset={mockReset} onHome={mockOnHome} />);
const button = screen.getByText('Retry Session');
fireEvent.click(button);
expect(mockReset).toHaveBeenCalledTimes(1);
});
it('calls onHome when Return to Pits is clicked', () => {
render(<FatalErrorTemplate viewData={mockViewData} reset={mockReset} onHome={mockOnHome} />);
const button = screen.getByText('Return to Pits');
fireEvent.click(button);
expect(mockOnHome).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { ErrorScreen } from '@/components/errors/ErrorScreen';
export interface FatalErrorViewData {
error: Error & { digest?: string };
}
interface FatalErrorTemplateProps {
viewData: FatalErrorViewData;
reset: () => void;
onHome: () => void;
}
/**
* FatalErrorTemplate
*
* The top-most error template for the global error boundary.
* Follows "Precision Racing Minimal" theme via ErrorScreen.
*/
export function FatalErrorTemplate({ viewData, reset, onHome }: FatalErrorTemplateProps) {
return (
<ErrorScreen
error={viewData.error}
reset={reset}
onHome={onHome}
/>
);
}

View File

@@ -1,23 +1,28 @@
'use client';
import { AlternatingSection } from '@/components/landing/AlternatingSection';
import React from 'react';
import { HomeHeader } from '@/components/home/HomeHeader';
import { HomeStatsStrip } from '@/components/home/HomeStatsStrip';
import { QuickLinksPanel } from '@/components/home/QuickLinksPanel';
import { HomeFeatureSection } from '@/components/home/HomeFeatureSection';
import { RecentRacesPanel } from '@/components/home/RecentRacesPanel';
import { LeagueSummaryPanel } from '@/components/home/LeagueSummaryPanel';
import { TeamSummaryPanel } from '@/components/home/TeamSummaryPanel';
import { HomeFeatureDescription } from '@/components/home/HomeFeatureDescription';
import { FAQ } from '@/components/landing/FAQ';
import { FeatureGrid } from '@/components/landing/FeatureGrid';
import { LandingHero } from '@/components/landing/LandingHero';
import { DiscoverySection } from '@/components/landing/DiscoverySection';
import { FeatureItem, ResultItem, StepItem } from '@/ui/LandingItems';
import { CareerProgressionMockup } from '@/components/mockups/CareerProgressionMockup';
import { CompanionAutomationMockup } from '@/components/mockups/CompanionAutomationMockup';
import { RaceHistoryMockup } from '@/components/mockups/RaceHistoryMockup';
import { SimPlatformMockup } from '@/components/mockups/SimPlatformMockup';
import { ModeGuard } from '@/components/shared/ModeGuard';
import { Box } from '@/ui/Box';
import { DiscordCTA } from '@/ui/DiscordCTA';
import { HomeFooterCTA } from '@/components/home/HomeFooterCTA';
import { Footer } from '@/ui/Footer';
import { Stack } from '@/ui/Stack';
import { ModeGuard } from '@/components/shared/ModeGuard';
import { CareerProgressionMockup } from '@/components/mockups/CareerProgressionMockup';
import { RaceHistoryMockup } from '@/components/mockups/RaceHistoryMockup';
import { CompanionAutomationMockup } from '@/components/mockups/CompanionAutomationMockup';
import { SimPlatformMockup } from '@/components/mockups/SimPlatformMockup';
import { Box } from '@/ui/Box';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { TelemetryLine } from '@/ui/TelemetryLine';
import { Glow } from '@/ui/Glow';
import { Grid } from '@/ui/Grid';
import { Section } from '@/ui/Section';
export interface HomeViewData {
isAlpha: boolean;
@@ -44,138 +49,135 @@ interface HomeTemplateProps {
viewData: HomeViewData;
}
/**
* HomeTemplate - Redesigned for "Precision Racing Minimal" theme.
* Composes semantic components instead of generic layout primitives.
*/
export function HomeTemplate({ viewData }: HomeTemplateProps) {
return (
<Box as="main" bg="graphite-black" position="relative" overflow="hidden">
<Glow color="primary" size="xl" position="top-right" opacity={0.05} />
<LandingHero />
<TelemetryLine color="primary" height="1px" opacity={0.3} />
<Box as="main" minHeight="screen" bg="graphite-black" color="text-white">
{/* Hero Section */}
<HomeHeader
title="Modern Motorsport Infrastructure."
subtitle="Precision Racing Infrastructure"
description="GridPilot gives your league racing a real home. Results, standings, teams, and career progression — engineered for precision and control."
primaryAction={{ label: 'Join the Grid', href: '#' }}
secondaryAction={{ label: 'Explore Leagues', href: '#' }}
/>
{/* Section 1: A Persistent Identity */}
<Box position="relative" bg="graphite-black">
<Glow color="aqua" size="lg" position="bottom-left" opacity={0.03} />
<AlternatingSection
heading="A Persistent Identity"
backgroundVideo="/gameplay.mp4"
description={
<Stack gap={8}>
<Text size="lg" color="text-gray-300" weight="medium" leading="relaxed">
Your races, your seasons, your progress &mdash; finally in one place.
</Text>
<Box display="grid" gridCols={1} gap={3}>
<FeatureItem text="Lifetime stats and season history across all your leagues" />
<FeatureItem text="Track your performance, consistency, and team contributions" />
<FeatureItem text="Your own rating that reflects real league competition" />
</Box>
<Box borderLeft borderStyle="solid" borderColor="primary-accent" pl={4} py={1} bg="primary-accent/5">
<Text color="text-gray-500" font="mono" size="xs" uppercase letterSpacing="widest">
iRacing gives you physics. GridPilot gives you a career.
</Text>
</Box>
</Stack>
}
mockup={<CareerProgressionMockup />}
layout="text-left"
/>
</Box>
{/* Telemetry Status Strip */}
<HomeStatsStrip />
<FeatureGrid />
{/* Quick Actions Bar */}
<QuickLinksPanel />
{/* Section 2: Results That Actually Stay */}
<Box position="relative" bg="graphite-black">
<Glow color="primary" size="lg" position="top-right" opacity={0.03} />
<AlternatingSection
heading="Results That Actually Stay"
backgroundImage="/images/ff1600.jpeg"
description={
<Stack gap={8}>
<Text size="lg" color="text-gray-300" weight="medium" leading="relaxed">
Every race you run stays with you.
</Text>
<Box display="grid" gridCols={1} gap={3}>
<ResultItem text="Your stats, your team, your story &mdash; all connected" color="#198CFF" />
<ResultItem text="One race result updates your profile, team points, rating, and season history" color="#198CFF" />
<ResultItem text="No more fragmented data across spreadsheets and forums" color="#198CFF" />
</Box>
<Box borderLeft borderStyle="solid" borderColor="telemetry-aqua" pl={4} py={1} bg="telemetry-aqua/5">
<Text color="text-gray-500" font="mono" size="xs" uppercase letterSpacing="widest">
Your racing career, finally in one place.
</Text>
</Box>
</Stack>
}
mockup={<RaceHistoryMockup />}
layout="text-right"
/>
</Box>
{/* Feature Sections */}
<HomeFeatureSection
heading="A Persistent Identity"
accentColor="primary"
layout="text-left"
description={
<HomeFeatureDescription
lead="Your races, your seasons, your progress — finally in one place."
items={[
'Lifetime stats and season history across all your leagues',
'Track your performance, consistency, and team contributions',
'Your own rating that reflects real league competition',
]}
quote="iRacing gives you physics. GridPilot gives you a career."
accentColor="primary"
/>
}
mockup={<CareerProgressionMockup />}
/>
<TelemetryLine color="aqua" height="1px" opacity={0.2} />
<HomeFeatureSection
heading="Results That Actually Stay"
accentColor="aqua"
layout="text-right"
description={
<HomeFeatureDescription
lead="Every race you run stays with you."
items={[
'Your stats, your team, your story — all connected',
'One race result updates your profile, team points, rating, and season history',
'No more fragmented data across spreadsheets and forums',
]}
quote="Your racing career, finally in one place."
accentColor="aqua"
/>
}
mockup={<RaceHistoryMockup />}
/>
{/* Section 3: Automatic Session Creation */}
<Box position="relative" bg="graphite-black">
<Glow color="amber" size="lg" position="bottom-right" opacity={0.02} />
<AlternatingSection
heading="Automatic Session Creation"
description={
<Stack gap={8}>
<Text size="lg" color="text-gray-300" weight="medium" leading="relaxed">
Setting up league races used to mean clicking through iRacing&apos;s wizard 20 times.
</Text>
<Box display="grid" gridCols={1} gap={3}>
<StepItem step={1} text="Our companion app syncs with your league schedule" />
<StepItem step={2} text="When it's race time, it creates the iRacing session automatically" />
<StepItem step={3} text="No clicking through wizards. No manual setup" />
</Box>
<Box borderLeft borderStyle="solid" borderColor="warning-amber" pl={4} py={1} bg="warning-amber/5">
<Text color="text-gray-500" font="mono" size="xs" uppercase letterSpacing="widest">
Automation instead of repetition.
</Text>
</Box>
</Stack>
}
mockup={<CompanionAutomationMockup />}
layout="text-left"
/>
</Box>
<HomeFeatureSection
heading="Automatic Session Creation"
accentColor="amber"
layout="text-left"
description={
<HomeFeatureDescription
lead="Setting up league races used to mean clicking through iRacing's wizard 20 times."
items={[
'Our companion app syncs with your league schedule',
'When it\'s race time, it creates the iRacing session automatically',
'No clicking through wizards. No manual setup',
]}
quote="Automation instead of repetition."
accentColor="amber"
/>
}
mockup={<CompanionAutomationMockup />}
/>
{/* Section 4: Game-Agnostic Platform */}
<Box position="relative" bg="graphite-black">
<Glow color="primary" size="xl" position="center" opacity={0.03} />
<AlternatingSection
heading="Built for iRacing. Ready for the future."
backgroundImage="/images/lmp3.jpeg"
description={
<Stack gap={8}>
<Text size="lg" color="text-gray-300" weight="medium" leading="relaxed">
Right now, we&apos;re focused on making iRacing league racing better.
</Text>
<Text color="text-gray-400" leading="relaxed">
But sims come and go. Your leagues, your teams, your rating &mdash; those stay.
</Text>
<Box borderLeft borderStyle="solid" borderColor="border-gray" pl={4} py={1} bg="white/5">
<Text color="text-gray-500" font="mono" size="xs" uppercase letterSpacing="widest" leading="relaxed">
GridPilot is built to outlast any single platform. When the next sim arrives, your competitive identity moves with you.
</Text>
</Box>
</Stack>
}
mockup={<SimPlatformMockup />}
layout="text-right"
/>
</Box>
<HomeFeatureSection
heading="Built for iRacing. Ready for the future."
accentColor="primary"
layout="text-right"
description={
<HomeFeatureDescription
lead="Right now, we're focused on making iRacing league racing better."
items={[
'But sims come and go. Your leagues, your teams, your rating — those stay.',
]}
quote="GridPilot is built to outlast any single platform."
accentColor="gray"
/>
}
mockup={<SimPlatformMockup />}
/>
{/* Alpha-only discovery section */}
{/* Discovery Grid */}
<ModeGuard feature="alpha_discovery">
<Box bg="panel-gray/20" py={20} borderTop borderBottom borderColor="border-gray/50" position="relative">
<Glow color="aqua" size="xl" position="center" opacity={0.02} />
<DiscoverySection viewData={viewData} />
</Box>
<Section py={24} variant="dark">
<Container>
<Box maxWidth="2xl" mb={16}>
<Box display="flex" alignItems="center" borderLeft borderStyle="solid" borderWidth="2px" borderColor="primary-accent" px={4} mb={4}>
<Text size="xs" weight="bold" uppercase letterSpacing="widest" color="text-primary-accent">
Live Ecosystem
</Text>
</Box>
<Heading level={2} fontSize={{ base: '3xl', md: '4xl' }} weight="bold" letterSpacing="tight" color="text-white" mb={6}>
DISCOVER THE GRID
</Heading>
<Text size="lg" color="text-gray-400" leading="relaxed">
Explore leagues, teams, and races that make up the GridPilot ecosystem.
</Text>
</Box>
<Grid cols={1} lgCols={3} gap={8}>
<LeagueSummaryPanel leagues={viewData.topLeagues} />
<TeamSummaryPanel teams={viewData.teams} />
<RecentRacesPanel races={viewData.upcomingRaces} />
</Grid>
</Container>
</Section>
</ModeGuard>
<DiscordCTA />
{/* CTA & FAQ */}
<HomeFooterCTA />
<FAQ />
{/* Footer */}
<Footer />
</Box>
);

View File

@@ -1,7 +1,6 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Container } from '@/ui/Container';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
@@ -27,14 +26,12 @@ export function LeaderboardsTemplate({
}: LeaderboardsTemplateProps) {
return (
<Container size="lg" py={8}>
<Box mb={10}>
<LeaderboardsHero
onNavigateToDrivers={onNavigateToDrivers}
onNavigateToTeams={onNavigateToTeams}
/>
</Box>
<LeaderboardsHero
onNavigateToDrivers={onNavigateToDrivers}
onNavigateToTeams={onNavigateToTeams}
/>
<Grid cols={12} gap={6}>
<Grid cols={12} gap={6} mt={10}>
<GridItem colSpan={12} lgSpan={6}>
<DriverLeaderboardPreview
drivers={viewData.drivers}

View File

@@ -11,6 +11,7 @@ import { Input } from '@/ui/Input';
import { Select } from '@/ui/Select';
import { Grid } from '@/ui/Grid';
import { Surface } from '@/ui/Surface';
import { InlineNotice } from '@/components/shared/ux/InlineNotice';
import type { LeagueAdminScheduleViewData } from '@/lib/view-data/LeagueAdminScheduleViewData';
interface LeagueAdminScheduleTemplateProps {
@@ -32,6 +33,7 @@ interface LeagueAdminScheduleTemplateProps {
isPublishing: boolean;
isSaving: boolean;
isDeleting: string | null;
error: string | null;
// Form setters
setTrack: (value: string) => void;
@@ -54,6 +56,7 @@ export function LeagueAdminScheduleTemplate({
isPublishing,
isSaving,
isDeleting,
error,
setTrack,
setCar,
setScheduledAtIso,
@@ -67,6 +70,13 @@ export function LeagueAdminScheduleTemplate({
return (
<Stack gap={6}>
{error && (
<InlineNotice
variant="error"
title="Action Failed"
message={error}
/>
)}
<Card>
<Stack gap={6}>
<Box>

View File

@@ -2,13 +2,13 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Breadcrumbs } from '@/ui/Breadcrumbs';
import { LeagueTabs } from '@/components/leagues/LeagueTabs';
import { LeagueHeader } from '@/components/leagues/LeagueHeader';
import { LeagueNavTabs } from '@/components/leagues/LeagueNavTabs';
import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData';
import { Link } from '@/ui/Link';
import { ChevronRight } from 'lucide-react';
import { usePathname } from 'next/navigation';
interface Tab {
label: string;
@@ -27,30 +27,38 @@ export function LeagueDetailTemplate({
tabs,
children,
}: LeagueDetailTemplateProps) {
const pathname = usePathname();
return (
<Container size="lg" py={6}>
<Stack gap={6}>
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Leagues', href: '/leagues' },
{ label: viewData.name },
]}
<Box minHeight="screen" bg="zinc-950" color="text-zinc-200">
<Box maxWidth="7xl" mx="auto" px={{ base: 4, sm: 6, lg: 8 }} py={8}>
{/* Breadcrumbs */}
<Box as="nav" display="flex" alignItems="center" gap={2} mb={8}>
<Link href="/" variant="ghost" size="xs" weight="medium">
<Text size="xs" weight="medium" uppercase letterSpacing="widest">Home</Text>
</Link>
<Box color="text-zinc-500"><ChevronRight size={12} /></Box>
<Link href="/leagues" variant="ghost" size="xs" weight="medium">
<Text size="xs" weight="medium" uppercase letterSpacing="widest">Leagues</Text>
</Link>
<Box color="text-zinc-500"><ChevronRight size={12} /></Box>
<Text size="xs" weight="medium" color="text-zinc-300" uppercase letterSpacing="widest">{viewData.name}</Text>
</Box>
<LeagueHeader
leagueId={viewData.leagueId}
leagueName={viewData.name}
description={viewData.description}
ownerId={viewData.ownerSummary?.driverId || ''}
ownerName={viewData.ownerSummary?.driverName || ''}
/>
<Box>
<Heading level={1}>{viewData.name}</Heading>
<Text color="text-gray-400" block mt={2}>
{viewData.description}
</Text>
</Box>
<LeagueNavTabs tabs={tabs} currentPathname={pathname} />
<LeagueTabs tabs={tabs} />
<Box>
<Box as="main">
{children}
</Box>
</Stack>
</Container>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData';
import { Trophy, Users, Calendar, Shield, type LucideIcon } from 'lucide-react';
interface LeagueOverviewTemplateProps {
viewData: LeagueDetailViewData;
}
export function LeagueOverviewTemplate({ viewData }: LeagueOverviewTemplateProps) {
return (
<Box display="grid" responsiveGridCols={{ base: 1, lg: 3 }} gap={8}>
{/* Main Content */}
<Box responsiveColSpan={{ lg: 2 }}>
<Stack gap={8}>
<Stack gap={4}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">About the League</Text>
<Box p={6} border borderColor="zinc-800" bg="zinc-900/30">
<Text color="text-zinc-300" leading="relaxed">
{viewData.description || 'No description provided for this league.'}
</Text>
</Box>
</Stack>
<Stack gap={4}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Quick Stats</Text>
<Box display="grid" responsiveGridCols={{ base: 2, md: 4 }} gap={4}>
<StatCard icon={Users} label="Members" value={viewData.info.membersCount} />
<StatCard icon={Calendar} label="Races" value={viewData.info.racesCount} />
<StatCard icon={Trophy} label="Avg SOF" value={viewData.info.avgSOF || '—'} />
<StatCard icon={Shield} label="Structure" value={viewData.info.structure} />
</Box>
</Stack>
</Stack>
</Box>
{/* Sidebar */}
<Box as="aside">
<Stack gap={8}>
<Stack gap={4}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Management</Text>
<Box p={6} border borderColor="zinc-800" bg="zinc-900/30">
<Stack gap={4}>
{viewData.ownerSummary && (
<Box display="flex" alignItems="center" gap={3}>
<Box w="10" h="10" bg="zinc-800" border borderColor="zinc-700" display="flex" alignItems="center" justifyContent="center" color="text-zinc-500">
<Users size={20} />
</Box>
<Stack gap={0}>
<Text size="xs" color="text-zinc-500" weight="bold" uppercase letterSpacing="0.05em">Owner</Text>
<Text size="sm" weight="bold" color="text-white">{viewData.ownerSummary.driverName}</Text>
</Stack>
</Box>
)}
<Stack gap={2}>
<Text size="xs" color="text-zinc-500" weight="bold" uppercase letterSpacing="0.05em">Admins</Text>
<Box display="flex" flexWrap="wrap" gap={2}>
{viewData.adminSummaries.map(admin => (
<Box key={admin.driverId} px={2} py={1} bg="zinc-800" color="text-zinc-400" border borderColor="zinc-700">
<Text size="xs" weight="bold" uppercase fontSize="10px">{admin.driverName}</Text>
</Box>
))}
{viewData.adminSummaries.length === 0 && <Text size="xs" color="text-zinc-600" italic>No admins assigned</Text>}
</Box>
</Stack>
</Stack>
</Box>
</Stack>
<Stack gap={4}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Sponsors</Text>
<Box p={6} border borderColor="zinc-800" bg="zinc-900/30">
<Stack gap={4}>
{viewData.sponsors.length > 0 ? (
viewData.sponsors.map(sponsor => (
<Box key={sponsor.id} display="flex" alignItems="center" gap={3}>
<Box w="8" h="8" bg="zinc-800" border borderColor="zinc-700" display="flex" alignItems="center" justifyContent="center" color="text-blue-500">
<Trophy size={16} />
</Box>
<Text size="sm" weight="bold" color="text-zinc-300">{sponsor.name}</Text>
</Box>
))
) : (
<Text size="xs" color="text-zinc-600" italic>No active sponsors</Text>
)}
</Stack>
</Box>
</Stack>
</Stack>
</Box>
</Box>
);
}
function StatCard({ icon: Icon, label, value }: { icon: LucideIcon, label: string, value: string | number }) {
return (
<Box display="flex" flexDirection="col" gap={2} p={4} border borderColor="zinc-800" bg="zinc-900/50">
<Box color="text-zinc-600">
<Icon size={16} />
</Box>
<Stack gap={0}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest" fontSize="10px">{label}</Text>
<Text size="lg" weight="bold" color="text-white" font="mono">{value}</Text>
</Stack>
</Box>
);
}

View File

@@ -1,19 +1,18 @@
'use client';
import React from 'react';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Badge } from '@/ui/Badge';
import { Grid } from '@/ui/Grid';
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
import { PointsTable } from '@/ui/PointsTable';
import { RulebookTabs, type RulebookSection } from '@/ui/RulebookTabs';
import type { LeagueRulebookViewData } from '@/lib/view-data/LeagueRulebookViewData';
import { Surface } from '@/ui/Surface';
import { Clock } from 'lucide-react';
import { Book, Shield, Scale, AlertTriangle, Info, Clock, type LucideIcon } from 'lucide-react';
import { Icon } from '@/ui/Icon';
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
interface LeagueRulebookTemplateProps {
viewData: LeagueRulebookViewData;
@@ -30,21 +29,20 @@ export function LeagueRulebookTemplate({
}: LeagueRulebookTemplateProps) {
if (loading) {
return (
<Card>
<Stack align="center" py={12}>
<Text color="text-gray-400">Loading rulebook...</Text>
</Stack>
</Card>
<Surface variant="dark" border rounded="lg" padding={12} center>
<Text color="text-gray-400">Loading rulebook...</Text>
</Surface>
);
}
if (!viewData || !viewData.scoringConfig) {
return (
<Card>
<Stack align="center" py={12}>
<Surface variant="dark" border rounded="lg" padding={12} center>
<Stack align="center" gap={3}>
<Icon icon={AlertTriangle} size={8} color="text-warning-amber" />
<Text color="text-gray-400">Unable to load rulebook</Text>
</Stack>
</Card>
</Surface>
);
}
@@ -54,17 +52,6 @@ export function LeagueRulebookTemplate({
return (
<Stack gap={6}>
{/* Header */}
<Stack direction="row" align="center" justify="between">
<Box>
<Heading level={1}>Rulebook</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>Official rules and regulations</Text>
</Box>
<Badge variant="primary">
{scoringConfig.scoringPresetName || 'Custom Rules'}
</Badge>
</Stack>
{/* Navigation Tabs */}
<RulebookTabs activeSection={activeSection} onSectionChange={onSectionChange} />
@@ -72,177 +59,158 @@ export function LeagueRulebookTemplate({
{activeSection === 'scoring' && (
<Stack gap={6}>
{/* Quick Stats */}
<Grid cols={4} gap={4}>
<StatItem label="Platform" value={scoringConfig.gameName} />
<StatItem label="Championships" value={scoringConfig.championships.length} />
<StatItem label="Sessions Scored" value={primaryChampionship?.sessionTypes.join(', ') || 'Main'} />
<StatItem label="Drop Policy" value={scoringConfig.dropPolicySummary.includes('All') ? 'None' : 'Active'} />
<Grid cols={1} mdCols={2} lgCols={4} gap={4}>
<StatItem icon={Info} label="Platform" value={scoringConfig.gameName} color="text-primary-blue" />
<StatItem icon={Book} label="Championships" value={scoringConfig.championships.length} color="text-neon-aqua" />
<StatItem icon={Clock} label="Sessions" value={primaryChampionship?.sessionTypes.join(', ') || 'Main'} color="text-performance-green" />
<StatItem icon={Shield} label="Drop Policy" value={scoringConfig.dropPolicySummary.includes('All') ? 'None' : 'Active'} color="text-warning-amber" />
</Grid>
{/* Weekend Structure */}
<Card>
<Surface variant="dark" border rounded="lg" padding={6}>
<Stack gap={4}>
<Stack direction="row" align="center" gap={2}>
<Clock size={20} color="#3b82f6" />
<Heading level={2}>Weekend Structure & Timings</Heading>
<Icon icon={Clock} size={5} color="text-primary-blue" />
<Heading level={5} color="text-primary-blue">WEEKEND STRUCTURE</Heading>
</Stack>
<Grid cols={4} gap={4}>
<Surface variant="muted" rounded="lg" border padding={3}>
<Text size="xs" color="text-gray-500" block mb={1}>PRACTICE</Text>
<Text weight="medium" color="text-white">20 min</Text>
</Surface>
<Surface variant="muted" rounded="lg" border padding={3}>
<Text size="xs" color="text-gray-500" block mb={1}>QUALIFYING</Text>
<Text weight="medium" color="text-white">30 min</Text>
</Surface>
<Surface variant="muted" rounded="lg" border padding={3}>
<Text size="xs" color="text-gray-500" block mb={1}>SPRINT</Text>
<Text weight="medium" color="text-white"></Text>
</Surface>
<Surface variant="muted" rounded="lg" border padding={3}>
<Text size="xs" color="text-gray-500" block mb={1}>MAIN RACE</Text>
<Text weight="medium" color="text-white">40 min</Text>
</Surface>
<Grid cols={2} mdCols={4} gap={4}>
<TimingItem label="PRACTICE" value="20 min" />
<TimingItem label="QUALIFYING" value="30 min" />
<TimingItem label="SPRINT" value="—" />
<TimingItem label="MAIN RACE" value="40 min" />
</Grid>
</Stack>
</Card>
</Surface>
{/* Points Table */}
<PointsTable points={positionPoints} />
{/* Bonus Points */}
{primaryChampionship?.bonusSummary && primaryChampionship.bonusSummary.length > 0 && (
<Card>
<Surface variant="dark" border rounded="lg" padding={6}>
<Stack gap={4}>
<Heading level={2}>Bonus Points</Heading>
<Heading level={5} color="text-performance-green">BONUS POINTS</Heading>
<Stack gap={2}>
{primaryChampionship.bonusSummary.map((bonus, idx) => (
<Surface
key={idx}
variant="muted"
rounded="lg"
border
padding={3}
>
<Stack direction="row" align="center" gap={4}>
<Surface variant="muted" rounded="full" padding={1} w="2rem" h="2rem" bg="bg-green-500/10" borderColor="border-green-500/20" display="flex" alignItems="center" justifyContent="center">
<Text color="text-performance-green" weight="bold">+</Text>
</Surface>
<Box key={idx} p={3} rounded="md" bg="bg-performance-green/5" border borderColor="border-performance-green/10">
<Stack direction="row" align="center" gap={3}>
<Box center w={6} h={6} rounded="full" bg="bg-performance-green/20">
<Text color="text-performance-green" weight="bold" size="xs">+</Text>
</Box>
<Text size="sm" color="text-gray-300">{bonus}</Text>
</Stack>
</Surface>
</Box>
))}
</Stack>
</Stack>
</Card>
)}
{/* Drop Policy */}
{!scoringConfig.dropPolicySummary.includes('All results count') && (
<Card>
<Stack gap={4}>
<Heading level={2}>Drop Policy</Heading>
<Text size="sm" color="text-gray-300">{scoringConfig.dropPolicySummary}</Text>
<Box mt={3}>
<Text size="xs" color="text-gray-500" block>
Drop rules are applied automatically when calculating championship standings.
</Text>
</Box>
</Stack>
</Card>
</Surface>
)}
</Stack>
)}
{activeSection === 'conduct' && (
<Card>
<Stack gap={4}>
<Heading level={2}>Driver Conduct</Heading>
<Surface variant="dark" border rounded="lg" padding={6}>
<Stack gap={6}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Shield} size={5} color="text-performance-green" />
<Heading level={5} color="text-performance-green">DRIVER CONDUCT</Heading>
</Stack>
<Stack gap={4}>
<ConductItem number={1} title="Respect" text="All drivers must treat each other with respect. Abusive language, harassment, or unsportsmanlike behavior will not be tolerated." />
<ConductItem number={2} title="Clean Racing" text="Intentional wrecking, blocking, or dangerous driving is prohibited. Leave space for other drivers and race cleanly." />
<ConductItem number={3} title="Track Limits" text="Drivers must stay within track limits. Gaining a lasting advantage by exceeding track limits may result in penalties." />
<ConductItem number={4} title="Blue Flags" text="Lapped cars must yield to faster traffic within a reasonable time. Failure to do so may result in penalties." />
<ConductItem number={5} title="Communication" text="Drivers are expected to communicate respectfully in voice and text chat during sessions." />
</Stack>
</Stack>
</Card>
</Surface>
)}
{activeSection === 'protests' && (
<Card>
<Stack gap={4}>
<Heading level={2}>Protest Process</Heading>
<Surface variant="dark" border rounded="lg" padding={6}>
<Stack gap={6}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Scale} size={5} color="text-warning-amber" />
<Heading level={5} color="text-warning-amber">PROTEST PROCESS</Heading>
</Stack>
<Stack gap={4}>
<ConductItem number={1} title="Filing a Protest" text="Protests can be filed within 48 hours of the race conclusion. Include the lap number, drivers involved, and a clear description of the incident." />
<ConductItem number={2} title="Evidence" text="Video evidence is highly recommended but not required. Stewards will review available replay data." />
<ConductItem number={3} title="Review Process" text="League stewards will review protests and make decisions within 72 hours. Decisions are final unless new evidence is presented." />
<ConductItem number={4} title="Outcomes" text="Protests may result in no action, warnings, time penalties, position penalties, or points deductions depending on severity." />
</Stack>
</Stack>
</Card>
</Surface>
)}
{activeSection === 'penalties' && (
<Card>
<Stack gap={4}>
<Heading level={2}>Penalty Guidelines</Heading>
<Stack gap={4}>
<Table>
<TableHead>
<TableRow>
<TableHeader>Infraction</TableHeader>
<TableHeader>Typical Penalty</TableHeader>
</TableRow>
</TableHead>
<TableBody>
<PenaltyRow infraction="Causing avoidable contact" penalty="5-10 second time penalty" />
<PenaltyRow infraction="Unsafe rejoin" penalty="5 second time penalty" />
<PenaltyRow infraction="Blocking" penalty="Warning or 3 second penalty" />
<PenaltyRow infraction="Repeated track limit violations" penalty="5 second penalty" />
<PenaltyRow infraction="Intentional wrecking" penalty="Disqualification" color="#f87171" />
<PenaltyRow infraction="Unsportsmanlike conduct" penalty="Points deduction or ban" color="#f87171" />
</TableBody>
</Table>
<Box mt={4}>
<Text size="xs" color="text-gray-500" block>
Penalties are applied at steward discretion based on incident severity and driver history.
</Text>
</Box>
<Surface variant="dark" border rounded="lg" padding={6}>
<Stack gap={6}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={AlertTriangle} size={5} color="text-error-red" />
<Heading level={5} color="text-error-red">PENALTY GUIDELINES</Heading>
</Stack>
<Table>
<TableHead>
<TableRow>
<TableHeader>Infraction</TableHeader>
<TableHeader>Typical Penalty</TableHeader>
</TableRow>
</TableHead>
<TableBody>
<PenaltyRow infraction="Causing avoidable contact" penalty="5-10 second time penalty" />
<PenaltyRow infraction="Unsafe rejoin" penalty="5 second time penalty" />
<PenaltyRow infraction="Blocking" penalty="Warning or 3 second penalty" />
<PenaltyRow infraction="Intentional wrecking" penalty="Disqualification" isSevere />
</TableBody>
</Table>
</Stack>
</Card>
</Surface>
)}
</Stack>
);
}
function StatItem({ label, value }: { label: string, value: string | number }) {
function StatItem({ icon, label, value, color }: { icon: LucideIcon, label: string, value: string | number, color: string }) {
return (
<Surface variant="muted" rounded="lg" border padding={4} bg="bg-neutral-800" borderColor="border-neutral-800">
<Text size="xs" color="text-gray-500" block mb={1}>{label.toUpperCase()}</Text>
<Text weight="semibold" color="text-white" size="lg">{value}</Text>
<Surface variant="dark" border rounded="lg" padding={4}>
<Stack direction="row" align="center" gap={3}>
<Box center w={10} h={10} rounded="lg" bg="bg-white/5">
<Icon icon={icon} size={5} color={color} />
</Box>
<Box>
<Text size="xs" color="text-gray-500" weight="bold" letterSpacing="widest" display="block" mb={0.5}>{label.toUpperCase()}</Text>
<Text weight="bold" color="text-white">{value}</Text>
</Box>
</Stack>
</Surface>
);
}
function TimingItem({ label, value }: { label: string, value: string }) {
return (
<Box p={3} rounded="md" bg="bg-white/5" border borderColor="border-white/10">
<Text size="xs" color="text-gray-500" weight="bold" letterSpacing="widest" display="block" mb={1}>{label}</Text>
<Text weight="bold" color="text-white">{value}</Text>
</Box>
);
}
function ConductItem({ number, title, text }: { number: number, title: string, text: string }) {
return (
<Box>
<Text weight="medium" color="text-white" block mb={2}>{number}. {title}</Text>
<Text size="sm" color="text-gray-300">{text}</Text>
<Box py={4} borderBottom borderColor="border-charcoal-outline">
<Text weight="bold" color="text-white" display="block" mb={1}>{number}. {title}</Text>
<Text size="sm" color="text-gray-400" lineHeight="relaxed">{text}</Text>
</Box>
);
}
function PenaltyRow({ infraction, penalty, color }: { infraction: string, penalty: string, color?: string }) {
function PenaltyRow({ infraction, penalty, isSevere }: { infraction: string, penalty: string, isSevere?: boolean }) {
return (
<TableRow>
<TableCell>
<Text size="sm" color="text-gray-300">{infraction}</Text>
</TableCell>
<TableCell>
<Text size="sm" color={color === '#f87171' ? 'text-error-red' : 'text-warning-amber'}>{penalty}</Text>
<Text size="sm" weight="bold" color={isSevere ? 'text-error-red' : 'text-warning-amber'}>{penalty}</Text>
</TableCell>
</TableRow>
);

View File

@@ -2,27 +2,32 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { LeagueSchedulePanel } from '@/components/leagues/LeagueSchedulePanel';
import type { LeagueScheduleViewData } from '@/lib/view-data/leagues/LeagueScheduleViewData';
import { LeagueSchedule } from '@/components/leagues/LeagueSchedule';
interface LeagueScheduleTemplateProps {
viewData: LeagueScheduleViewData;
}
export function LeagueScheduleTemplate({ viewData }: LeagueScheduleTemplateProps) {
const events = viewData.races.map(race => ({
id: race.id,
title: race.name || `Race ${race.id.substring(0, 4)}`,
trackName: race.track || 'TBA',
date: race.scheduledAt,
time: new Date(race.scheduledAt).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
status: (race.status as 'upcoming' | 'live' | 'completed') || 'upcoming'
}));
return (
<Stack gap={6}>
<Box>
<Heading level={2}>Race Schedule</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
Upcoming and completed races for this season
</Text>
<Box display="flex" flexDirection="col" gap={8}>
<Box as="header" display="flex" flexDirection="col" gap={2}>
<Text as="h2" size="xl" weight="bold" color="text-white" uppercase letterSpacing="tight">Race Schedule</Text>
<Text size="sm" color="text-zinc-500">Upcoming and past events for this season.</Text>
</Box>
<LeagueSchedule leagueId={viewData.leagueId} />
</Stack>
<LeagueSchedulePanel events={events} />
</Box>
);
}

View File

@@ -1,7 +1,6 @@
'use client';
import React from 'react';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
@@ -10,8 +9,7 @@ import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
import { Settings, Users, Trophy, Shield, Clock, LucideIcon } from 'lucide-react';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { Settings, Users, Trophy, Shield, Clock, type LucideIcon } from 'lucide-react';
import type { LeagueSettingsViewData } from '@/lib/view-data/leagues/LeagueSettingsViewData';
interface LeagueSettingsTemplateProps {
@@ -20,76 +18,69 @@ interface LeagueSettingsTemplateProps {
export function LeagueSettingsTemplate({ viewData }: LeagueSettingsTemplateProps) {
return (
<Stack gap={6}>
<Box>
<Heading level={2}>League Settings</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
Manage your league configuration and preferences
</Text>
</Box>
<Stack gap={8}>
<Stack gap={6}>
{/* League Information */}
<Card>
<Stack gap={4}>
<Surface variant="dark" border rounded="lg" padding={6}>
<Stack gap={6}>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} bg="bg-blue-500/10">
<Icon icon={Settings} size={5} color="#3b82f6" />
</Surface>
<Box p={2} bg="bg-primary-blue/10" rounded="md" border borderColor="border-primary-blue/20">
<Icon icon={Settings} size={5} color="text-primary-blue" />
</Box>
<Box>
<Heading level={3}>League Information</Heading>
<Text size="sm" color="text-gray-400">Basic league details</Text>
<Heading level={5} color="text-primary-blue">LEAGUE INFORMATION</Heading>
<Text size="xs" color="text-gray-500">Basic league details and identification</Text>
</Box>
</Stack>
<Grid cols={2} gap={4}>
<Grid cols={1} mdCols={2} gap={6}>
<InfoItem label="Name" value={viewData.league.name} />
<InfoItem label="Visibility" value={viewData.league.visibility} capitalize />
<GridItem colSpan={2}>
<InfoItem label="Description" value={viewData.league.description} />
</GridItem>
<InfoItem label="Created" value={DateDisplay.formatShort(viewData.league.createdAt)} />
<InfoItem label="Created" value={new Date(viewData.league.createdAt).toLocaleDateString()} />
<InfoItem label="Owner ID" value={viewData.league.ownerId} />
</Grid>
</Stack>
</Card>
</Surface>
{/* Configuration */}
<Card>
<Stack gap={4}>
<Surface variant="dark" border rounded="lg" padding={6}>
<Stack gap={6}>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} bg="bg-green-500/10">
<Icon icon={Trophy} size={5} color="#10b981" />
</Surface>
<Box p={2} bg="bg-performance-green/10" rounded="md" border borderColor="border-performance-green/20">
<Icon icon={Trophy} size={5} color="text-performance-green" />
</Box>
<Box>
<Heading level={3}>Configuration</Heading>
<Text size="sm" color="text-gray-400">League rules and limits</Text>
<Heading level={5} color="text-performance-green">CONFIGURATION</Heading>
<Text size="xs" color="text-gray-500">League rules and participation limits</Text>
</Box>
</Stack>
<Grid cols={2} gap={4}>
<Grid cols={1} mdCols={2} gap={6}>
<ConfigItem icon={Users} label="Max Drivers" value={viewData.config.maxDrivers} />
<ConfigItem icon={Shield} label="Require Approval" value={viewData.config.requireApproval ? 'Yes' : 'No'} />
<ConfigItem icon={Clock} label="Allow Late Join" value={viewData.config.allowLateJoin ? 'Yes' : 'No'} />
<ConfigItem icon={Trophy} label="Scoring Preset" value={viewData.config.scoringPresetId} />
</Grid>
</Stack>
</Card>
</Surface>
{/* Note about forms */}
<Card>
<Stack align="center" py={8} gap={4}>
<Surface variant="muted" rounded="full" padding={4} bg="bg-amber-500/10">
<Icon icon={Settings} size={8} color="#f59e0b" />
</Surface>
<Surface variant="dark" border rounded="lg" padding={8}>
<Stack align="center" gap={4}>
<Box p={4} bg="bg-warning-amber/10" rounded="full" border borderColor="border-warning-amber/20">
<Icon icon={Settings} size={8} color="text-warning-amber" />
</Box>
<Box textAlign="center">
<Heading level={3}>Settings Management</Heading>
<Text size="sm" color="text-gray-400" block mt={2}>
<Text size="sm" color="text-gray-400" mt={2} display="block">
Form-based editing and ownership transfer functionality will be implemented in future updates.
</Text>
</Box>
</Stack>
</Card>
</Surface>
</Stack>
</Stack>
);
@@ -98,19 +89,21 @@ export function LeagueSettingsTemplate({ viewData }: LeagueSettingsTemplateProps
function InfoItem({ label, value, capitalize }: { label: string, value: string, capitalize?: boolean }) {
return (
<Box>
<Text size="sm" weight="medium" color="text-gray-400" block mb={1}>{label}</Text>
<Text color="text-white">{capitalize ? value.toUpperCase() : value}</Text>
<Text size="xs" weight="bold" color="text-gray-500" display="block" mb={1} letterSpacing="wider">{label.toUpperCase()}</Text>
<Text color="text-white" weight="medium">{capitalize ? value.toUpperCase() : value}</Text>
</Box>
);
}
function ConfigItem({ icon, label, value }: { icon: LucideIcon, label: string, value: string | number }) {
return (
<Stack direction="row" align="center" gap={3}>
<Icon icon={icon} size={5} color="#9ca3af" />
<Stack direction="row" align="center" gap={4}>
<Box center w={10} h={10} rounded="lg" bg="bg-white/5">
<Icon icon={icon} size={5} color="text-gray-400" />
</Box>
<Box>
<Text size="sm" weight="medium" color="text-gray-400" block>{label}</Text>
<Text color="text-white">{value}</Text>
<Text size="xs" weight="bold" color="text-gray-500" display="block" letterSpacing="wider">{label.toUpperCase()}</Text>
<Text color="text-white" weight="medium">{value}</Text>
</Box>
</Stack>
);

View File

@@ -1,12 +1,9 @@
'use client';
import React from 'react';
import { LeagueChampionshipStats } from '@/components/leagues/LeagueChampionshipStats';
import { StandingsTable } from '@/components/leagues/StandingsTable';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { LeagueStandingsTable } from '@/components/leagues/LeagueStandingsTable';
import type { LeagueStandingsViewData } from '@/lib/view-data/LeagueStandingsViewData';
interface LeagueStandingsTemplateProps {
@@ -18,37 +15,38 @@ interface LeagueStandingsTemplateProps {
export function LeagueStandingsTemplate({
viewData,
onRemoveMember,
onUpdateRole,
loading = false,
}: LeagueStandingsTemplateProps) {
if (loading) {
return (
<Stack align="center" py={12}>
<Text color="text-gray-400">Loading standings...</Text>
</Stack>
<Box display="flex" alignItems="center" justifyContent="center" py={24}>
<Text color="text-zinc-500" font="mono" size="xs" uppercase letterSpacing="widest" animate="pulse">
Loading Standings...
</Text>
</Box>
);
}
return (
<Stack gap={6}>
{/* Championship Stats */}
<LeagueChampionshipStats standings={viewData.standings} drivers={viewData.drivers} />
const standings = viewData.standings.map((entry) => {
const driver = viewData.drivers.find(d => d.id === entry.driverId);
return {
position: entry.position,
driverName: driver?.name || 'Unknown Driver',
points: entry.totalPoints,
wins: 0,
podiums: 0,
gap: entry.position === 1 ? '—' : `-${viewData.standings[0].totalPoints - entry.totalPoints}`
};
});
<Card>
<Stack gap={4}>
<Heading level={2}>Championship Standings</Heading>
<StandingsTable
standings={viewData.standings}
drivers={viewData.drivers}
memberships={viewData.memberships}
currentDriverId={viewData.currentDriverId ?? undefined}
isAdmin={viewData.isAdmin}
onRemoveMember={onRemoveMember}
onUpdateRole={onUpdateRole}
/>
</Stack>
</Card>
</Stack>
return (
<Box display="flex" flexDirection="col" gap={8}>
<Box as="header" display="flex" flexDirection="col" gap={2}>
<Text as="h2" size="xl" weight="bold" color="text-white" uppercase letterSpacing="tight">Championship Standings</Text>
<Text size="sm" color="text-zinc-500">Official points classification for the current season.</Text>
</Box>
<LeagueStandingsTable standings={standings} />
</Box>
);
}

View File

@@ -0,0 +1,24 @@
'use client';
import React from 'react';
import { MediaGallery } from '@/components/media/MediaGallery';
import { Container } from '@/ui/Container';
import { Box } from '@/ui/Box';
import { MediaViewData } from '@/lib/view-data/MediaViewData';
export function MediaTemplate(viewData: MediaViewData) {
const { assets, categories, title, description } = viewData;
return (
<Container py={8}>
<Box display="flex" flexDirection="col" gap={8}>
<MediaGallery
assets={assets}
categories={categories}
title={title}
description={description}
/>
</Box>
</Container>
);
}

View File

@@ -0,0 +1,31 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { NotFoundTemplate, type NotFoundViewData } from './NotFoundTemplate';
describe('NotFoundTemplate', () => {
const mockViewData: NotFoundViewData = {
errorCode: 'Error 404',
title: 'OFF TRACK',
message: 'The requested sector does not exist.',
actionLabel: 'Return to Pits'
};
const mockOnHomeClick = vi.fn();
it('renders the error code, title and message', () => {
render(<NotFoundTemplate viewData={mockViewData} onHomeClick={mockOnHomeClick} />);
expect(screen.getByText('Error 404')).toBeDefined();
expect(screen.getByText('OFF TRACK')).toBeDefined();
expect(screen.getByText('The requested sector does not exist.')).toBeDefined();
});
it('calls onHomeClick when the button is clicked', () => {
render(<NotFoundTemplate viewData={mockViewData} onHomeClick={mockOnHomeClick} />);
const button = screen.getByText('Return to Pits');
fireEvent.click(button);
expect(mockOnHomeClick).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,34 @@
'use client';
import React from 'react';
import { NotFoundScreen } from '@/components/errors/NotFoundScreen';
export interface NotFoundViewData {
errorCode: string;
title: string;
message: string;
actionLabel: string;
}
interface NotFoundTemplateProps {
viewData: NotFoundViewData;
onHomeClick: () => void;
}
/**
* NotFoundTemplate
*
* Template for the 404 page.
* Composes semantic components to build the page layout.
*/
export function NotFoundTemplate({ viewData, onHomeClick }: NotFoundTemplateProps) {
return (
<NotFoundScreen
errorCode={viewData.errorCode}
title={viewData.title}
message={viewData.message}
actionLabel={viewData.actionLabel}
onActionClick={onHomeClick}
/>
);
}

View File

@@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { ProfileSidebarTemplate } from '@/templates/ProfileSidebarTemplate';
import { ProfileLayoutViewData } from '@/lib/view-data/ProfileLayoutViewData';
interface ProfileLayoutShellTemplateProps {
viewData: ProfileLayoutViewData;
children: ReactNode;
}
export function ProfileLayoutShellTemplate({ viewData, children }: ProfileLayoutShellTemplateProps) {
return (
<Box minHeight="screen" backgroundColor="#0C0D0F">
<Container size="lg" py={8}>
<Stack direction="row" gap={8} alignItems="start">
<Box as="aside" width="64" flexShrink={0}>
<ProfileSidebarTemplate viewData={viewData} />
</Box>
<Box as="main" flexGrow={1} minWidth="0">
{children}
</Box>
</Stack>
</Container>
</Box>
);
}

View File

@@ -1,14 +1,11 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Container } from '@/ui/Container';
import { Surface } from '@/ui/Surface';
import { Box } from '@/ui/Box';
import type { ProfileLeaguesViewData } from '@/lib/view-data/ProfileLeaguesViewData';
import { LeagueListItem } from '@/ui/LeagueListItem';
import { MembershipPanel } from '@/components/profile/MembershipPanel';
interface ProfileLeaguesTemplateProps {
viewData: ProfileLeaguesViewData;
@@ -16,67 +13,27 @@ interface ProfileLeaguesTemplateProps {
export function ProfileLeaguesTemplate({ viewData }: ProfileLeaguesTemplateProps) {
return (
<Container size="md" py={8}>
<Stack gap={8}>
<Box>
<Heading level={1}>Manage leagues</Heading>
<Text color="text-gray-400" size="sm" block mt={2}>
View leagues you own and participate in, and jump into league admin tools.
</Text>
</Box>
{/* Leagues You Own */}
<Surface variant="muted" rounded="lg" border padding={6}>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={2}>Leagues you own</Heading>
{viewData.ownedLeagues.length > 0 && (
<Text size="xs" color="text-gray-400">
{viewData.ownedLeagues.length} {viewData.ownedLeagues.length === 1 ? 'league' : 'leagues'}
</Text>
)}
</Stack>
{viewData.ownedLeagues.length === 0 ? (
<Text size="sm" color="text-gray-400">
You don&apos;t own any leagues yet in this session.
</Text>
) : (
<Stack gap={3}>
{viewData.ownedLeagues.map((league) => (
<LeagueListItem key={league.leagueId} league={league} isAdmin />
))}
</Stack>
)}
</Stack>
</Surface>
{/* Leagues You're In */}
<Surface variant="muted" rounded="lg" border padding={6}>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={2}>Leagues you&apos;re in</Heading>
{viewData.memberLeagues.length > 0 && (
<Text size="xs" color="text-gray-400">
{viewData.memberLeagues.length} {viewData.memberLeagues.length === 1 ? 'league' : 'leagues'}
</Text>
)}
</Stack>
{viewData.memberLeagues.length === 0 ? (
<Text size="sm" color="text-gray-400">
You&apos;re not a member of any other leagues yet.
</Text>
) : (
<Stack gap={3}>
{viewData.memberLeagues.map((league) => (
<LeagueListItem key={league.leagueId} league={league} />
))}
</Stack>
)}
</Stack>
</Surface>
</Stack>
</Container>
<Stack gap={8}>
<Box as="header">
<Heading level={1}>My Leagues</Heading>
</Box>
<Box as="main">
<MembershipPanel
ownedLeagues={viewData.ownedLeagues.map(l => ({
...l,
description: '', // ViewData doesn't have description, but LeagueListItem needs it
memberCount: 0, // ViewData doesn't have memberCount
roleLabel: 'Owner'
}))}
memberLeagues={viewData.memberLeagues.map(l => ({
...l,
description: '',
memberCount: 0,
roleLabel: 'Member'
}))}
/>
</Box>
</Stack>
);
}

View File

@@ -0,0 +1,39 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { Button } from '@/ui/Button';
import { Plus } from 'lucide-react';
import { routes } from '@/lib/routing/RouteConfig';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { LiveryGallery } from '@/components/profile/LiveryGallery';
import type { ProfileLiveriesViewData } from '@/lib/view-data/ProfileLiveriesViewData';
interface ProfileLiveriesTemplateProps {
viewData: ProfileLiveriesViewData;
}
export function ProfileLiveriesTemplate({ viewData }: ProfileLiveriesTemplateProps) {
return (
<Stack gap={8}>
<Box as="header">
<Stack direction="row" justify="between" align="center">
<Heading level={1}>My Liveries</Heading>
<Link href={routes.protected.profileLiveryUpload}>
<Button variant="primary" size="sm" icon={<Plus size={16} />}>
Upload Livery
</Button>
</Link>
</Stack>
</Box>
<Box as="main">
<LiveryGallery
liveries={viewData.liveries}
/>
</Box>
</Stack>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import React from 'react';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { ProfileDetailsPanel } from '@/components/profile/ProfileDetailsPanel';
import { ConnectedAccountsPanel } from '@/components/profile/ConnectedAccountsPanel';
import { PreferencesPanel } from '@/components/profile/PreferencesPanel';
import type { ProfileViewData } from '@/lib/view-data/ProfileViewData';
interface ProfileSettingsTemplateProps {
viewData: ProfileViewData;
bio: string;
country: string;
onBioChange: (bio: string) => void;
onCountryChange: (country: string) => void;
onSave: () => void;
}
export function ProfileSettingsTemplate({
viewData,
bio,
country,
onBioChange,
onCountryChange,
onSave
}: ProfileSettingsTemplateProps) {
return (
<Stack gap={8}>
<Box as="header">
<Stack direction="row" justify="between" align="center">
<Heading level={1}>Settings</Heading>
<Button variant="primary" onClick={onSave}>Save Changes</Button>
</Stack>
</Box>
<ProfileDetailsPanel
driver={{
name: viewData.driver.name,
country: country,
bio: bio
}}
isEditing
onUpdate={(updates) => {
if (updates.bio !== undefined) onBioChange(updates.bio);
if (updates.country !== undefined) onCountryChange(updates.country);
}}
/>
<ConnectedAccountsPanel
iracingId={viewData.driver.iracingId || undefined}
/>
<PreferencesPanel
preferences={{
favoriteCarClass: 'GT3',
favoriteSeries: 'Endurance',
competitiveLevel: 'competitive',
showProfile: true,
showHistory: true
}}
isEditing
/>
</Stack>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import {
User,
Settings,
Trophy,
Palette,
Handshake
} from 'lucide-react';
import { routes } from '@/lib/routing/RouteConfig';
import { ProfileLayoutViewData } from '@/lib/view-data/ProfileLayoutViewData';
export function ProfileSidebarTemplate({ viewData: _viewData }: { viewData: ProfileLayoutViewData }) {
const pathname = usePathname();
const navItems = [
{ label: 'Overview', href: routes.protected.profile, icon: User },
{ label: 'Leagues', href: routes.protected.profileLeagues, icon: Trophy },
{ label: 'Liveries', href: routes.protected.profileLiveries, icon: Palette },
{ label: 'Sponsorships', href: routes.protected.profileSponsorshipRequests, icon: Handshake },
{ label: 'Settings', href: routes.protected.profileSettings, icon: Settings },
];
return (
<Box width="240px" flexShrink={0}>
<Stack gap={1}>
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link key={item.href} href={item.href}>
<Box
px={4}
py={2.5}
rounded="md"
backgroundColor={isActive ? 'rgba(25, 140, 255, 0.1)' : 'transparent'}
color={isActive ? '#198CFF' : '#9ca3af'}
transition
display="flex"
alignItems="center"
gap={3}
cursor="pointer"
border
borderColor={isActive ? 'rgba(25, 140, 255, 0.2)' : 'transparent'}
>
<Icon icon={item.icon} size={4} color={isActive ? '#198CFF' : '#9ca3af'} />
<Text
size="sm"
weight={isActive ? 'bold' : 'medium'}
color={isActive ? '#198CFF' : '#9ca3af'}
>
{item.label}
</Text>
{isActive && (
<Box
ml="auto"
width="4px"
height="4px"
rounded="full"
backgroundColor="#198CFF"
/>
)}
</Box>
</Link>
);
})}
</Stack>
</Box>
);
}

View File

@@ -2,43 +2,30 @@
import React from 'react';
import { CreateDriverForm } from '@/components/drivers/CreateDriverForm';
import { ProfileRaceHistory } from '@/components/drivers/ProfileRaceHistory';
import { ProfileSettings } from '@/components/drivers/ProfileSettings';
import { AchievementGrid } from '@/components/achievements/AchievementGrid';
import { ProfileHero } from '@/components/drivers/ProfileHero';
import { ProfileHeader } from '@/components/profile/ProfileHeader';
import { ProfileNavTabs, type ProfileTab } from '@/components/profile/ProfileNavTabs';
import { ProfileDetailsPanel } from '@/components/profile/ProfileDetailsPanel';
import { SessionHistoryTable } from '@/components/profile/SessionHistoryTable';
import { ProfileStatGrid } from '@/ui/ProfileStatGrid';
import { ProfileTabs, type ProfileTab as ProfileTabsType } from '@/ui/ProfileTabs';
import { TeamMembershipGrid } from '@/ui/TeamMembershipGrid';
import { AchievementGrid } from '@/components/achievements/AchievementGrid';
import type { ProfileViewData } from '@/lib/view-data/ProfileViewData';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
import {
Activity,
Award,
History,
User,
} from 'lucide-react';
import { Breadcrumbs } from '@/ui/Breadcrumbs';
export type ProfileTab = 'overview' | 'history' | 'stats';
import { User } from 'lucide-react';
interface ProfileTemplateProps {
viewData: ProfileViewData;
mode: 'profile-exists' | 'needs-profile';
activeTab: ProfileTab;
onTabChange: (tab: ProfileTab) => void;
editMode: boolean;
onEditModeChange: (edit: boolean) => void;
friendRequestSent: boolean;
onFriendRequestSend: () => void;
onSaveSettings: (updates: { bio?: string; country?: string }) => Promise<void>;
}
export function ProfileTemplate({
@@ -46,26 +33,21 @@ export function ProfileTemplate({
mode,
activeTab,
onTabChange,
editMode,
onEditModeChange,
friendRequestSent,
onFriendRequestSend,
onSaveSettings,
}: ProfileTemplateProps) {
if (mode === 'needs-profile') {
return (
<Container size="md">
<Stack align="center" gap={4} mb={8}>
<Surface variant="muted" rounded="xl" border padding={4}>
<Icon icon={User} size={8} color="#3b82f6" />
</Surface>
<Box>
<Heading level={1}>Create Your Driver Profile</Heading>
<Text color="text-gray-400">Join the GridPilot community and start your racing journey</Text>
</Box>
</Stack>
<Stack align="center" gap={4} mb={8}>
<Surface variant="muted" rounded="xl" border padding={4}>
<Icon icon={User} size={8} color="#3b82f6" />
</Surface>
<Box>
<Heading level={1}>Create Your Driver Profile</Heading>
<Text color="text-gray-400">Join the GridPilot community and start your racing journey</Text>
</Box>
<Box maxWidth="42rem" mx="auto">
<Box maxWidth="42rem" mx="auto" width="100%">
<Card>
<Stack gap={6}>
<Box>
@@ -78,123 +60,89 @@ export function ProfileTemplate({
</Stack>
</Card>
</Box>
</Container>
);
}
if (editMode) {
return (
<Container size="md">
<Stack gap={6}>
<Stack direction="row" align="center" justify="between">
<Heading level={1}>Edit Profile</Heading>
<Button variant="secondary" onClick={() => onEditModeChange(false)}>
Cancel
</Button>
</Stack>
<ProfileSettings
driver={{
id: viewData.driver.id,
name: viewData.driver.name,
country: viewData.driver.countryCode,
avatarUrl: viewData.driver.avatarUrl,
iracingId: viewData.driver.iracingId || '',
joinedAt: new Date().toISOString(),
rating: null,
globalRank: null,
consistency: null,
bio: viewData.driver.bio,
totalDrivers: null,
}}
onSave={async (updates) => {
await onSaveSettings(updates);
onEditModeChange(false);
}}
/>
</Stack>
</Container>
</Stack>
);
}
return (
<Container size="lg">
<Stack gap={6}>
{/* Back Navigation */}
<Box>
<Button
variant="secondary"
onClick={() => {}}
icon={<Icon icon={History} size={4} />}
>
Back to Drivers
</Button>
</Box>
<Stack gap={8}>
<ProfileHeader
driver={{
...viewData.driver,
country: viewData.driver.countryCode,
iracingId: Number(viewData.driver.iracingId) || 0,
joinedAt: new Date().toISOString(), // Placeholder
}}
stats={viewData.stats ? { rating: Number(viewData.stats.ratingLabel) || 0 } : null}
globalRank={Number(viewData.stats?.globalRankLabel) || 0}
onAddFriend={onFriendRequestSend}
friendRequestSent={friendRequestSent}
isOwnProfile={true}
/>
{/* Breadcrumb */}
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Drivers', href: '/drivers' },
{ label: viewData.driver.name },
]}
/>
<ProfileNavTabs activeTab={activeTab} onTabChange={onTabChange} />
<ProfileHero
driver={{
...viewData.driver,
country: viewData.driver.countryCode,
iracingId: Number(viewData.driver.iracingId) || 0,
joinedAt: new Date().toISOString(), // Placeholder
}}
stats={viewData.stats ? { rating: Number(viewData.stats.ratingLabel) || 0 } : null}
globalRank={Number(viewData.stats?.globalRankLabel) || 0}
timezone={viewData.extendedProfile?.timezone || 'UTC'}
socialHandles={viewData.extendedProfile?.socialHandles.map(s => ({ ...s, platform: s.platformLabel })) || []}
onAddFriend={onFriendRequestSend}
friendRequestSent={friendRequestSent}
/>
{viewData.driver.bio && (
<Card>
<Stack gap={3}>
<Heading level={2} icon={<Icon icon={User} size={5} color="#3b82f6" />}>
About
</Heading>
<Text color="text-gray-300" block>{viewData.driver.bio}</Text>
</Stack>
</Card>
)}
{viewData.teamMemberships.length > 0 && (
<TeamMembershipGrid
memberships={viewData.teamMemberships.map(m => ({
team: { id: m.teamId, name: m.teamName },
role: m.roleLabel,
joinedAt: new Date() // Placeholder
}))}
{activeTab === 'overview' && (
<Stack gap={8}>
<ProfileDetailsPanel
driver={{
name: viewData.driver.name,
country: viewData.driver.countryCode,
bio: viewData.driver.bio
}}
/>
)}
<ProfileTabs activeTab={activeTab as unknown as ProfileTabsType} onTabChange={onTabChange as unknown as (tab: ProfileTabsType) => void} />
{viewData.teamMemberships.length > 0 && (
<Box as="section" aria-labelledby="teams-heading">
<Stack gap={4}>
<Heading level={3} id="teams-heading" fontSize="1.125rem">Teams</Heading>
<TeamMembershipGrid
memberships={viewData.teamMemberships.map(m => ({
team: { id: m.teamId, name: m.teamName },
role: m.roleLabel,
joinedAt: new Date() // Placeholder
}))}
/>
</Stack>
</Box>
)}
{activeTab === 'history' && (
<Card>
<Stack gap={4}>
<Heading level={2} icon={<Icon icon={History} size={5} color="#f87171" />}>
Race History
</Heading>
<ProfileRaceHistory driverId={viewData.driver.id} />
</Stack>
</Card>
)}
{viewData.extendedProfile && (
<Box as="section" aria-labelledby="achievements-heading">
<Stack gap={4}>
<Stack direction="row" justify="between" align="center">
<Heading level={3} id="achievements-heading" fontSize="1.125rem">Achievements</Heading>
<Text size="sm" color="#6b7280">{viewData.extendedProfile.achievements.length} earned</Text>
</Stack>
<AchievementGrid
achievements={viewData.extendedProfile.achievements.map(a => ({
...a,
rarity: a.rarityLabel,
earnedAt: new Date() // Placeholder
}))}
/>
</Stack>
</Box>
)}
</Stack>
)}
{activeTab === 'stats' && viewData.stats && (
<Card>
<Stack gap={6}>
<Heading level={2} icon={<Icon icon={Activity} size={5} color="#00f2ff" />}>
Performance Overview
</Heading>
{activeTab === 'history' && (
<Box as="section" aria-labelledby="history-heading">
<Stack gap={4}>
<Heading level={3} id="history-heading" fontSize="1.125rem">Race History</Heading>
<Card>
<SessionHistoryTable results={[]} />
</Card>
</Stack>
</Box>
)}
{activeTab === 'stats' && viewData.stats && (
<Box as="section" aria-labelledby="stats-heading">
<Stack gap={4}>
<Heading level={3} id="stats-heading" fontSize="1.125rem">Performance Overview</Heading>
<Card>
<ProfileStatGrid
stats={[
{ label: 'Races', value: viewData.stats.totalRacesLabel },
@@ -203,30 +151,10 @@ export function ProfileTemplate({
{ label: 'Consistency', value: viewData.stats.consistencyLabel, color: '#3b82f6' },
]}
/>
</Stack>
</Card>
)}
{activeTab === 'overview' && viewData.extendedProfile && (
<Card>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={2} icon={<Icon icon={Award} size={5} color="#facc15" />}>
Achievements
</Heading>
<Text size="sm" color="text-gray-400" weight="normal">{viewData.extendedProfile.achievements.length} earned</Text>
</Stack>
<AchievementGrid
achievements={viewData.extendedProfile.achievements.map(a => ({
...a,
rarity: a.rarityLabel,
earnedAt: new Date() // Placeholder
}))}
/>
</Stack>
</Card>
)}
</Stack>
</Container>
</Card>
</Stack>
</Box>
)}
</Stack>
);
}

View File

@@ -1,35 +1,20 @@
'use client';
import React from 'react';
import { Breadcrumbs } from '@/ui/Breadcrumbs';
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Container } from '@/ui/Container';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { Skeleton } from '@/ui/Skeleton';
import { InfoBox } from '@/ui/InfoBox';
import { RaceJoinButton } from '@/ui/RaceJoinButton';
import { RaceHero } from '@/ui/RaceHeroWrapper';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { RaceUserResult } from '@/ui/RaceUserResultWrapper';
import { RaceEntryList } from '@/components/races/RaceEntryList';
import { RaceDetailCard } from '@/ui/RaceDetailCard';
import { LeagueSummaryCard } from '@/components/leagues/LeagueSummaryCardWrapper';
import {
AlertTriangle,
ArrowLeft,
CheckCircle2,
Clock,
PlayCircle,
Trophy,
XCircle,
Scale,
} from 'lucide-react';
import { Surface } from '@/ui/Surface';
import { Card } from '@/ui/Card';
import { RaceActionBar } from '@/ui/RaceActionBar';
import { RaceDetailsHeader } from '@/components/races/RaceDetailsHeader';
import { TrackConditionsPanel } from '@/components/races/TrackConditionsPanel';
import { EntrantsTable } from '@/components/races/EntrantsTable';
import type { SessionStatus } from '@/components/races/SessionStatusBadge';
export interface RaceDetailEntryViewModel {
id: string;
@@ -127,7 +112,6 @@ export function RaceDetailTemplate({
onFileProtest,
onResultsClick,
onStewardingClick,
onDriverClick,
isOwnerOrAdmin = false,
animatedRatingChange,
mutationLoading = {},
@@ -152,174 +136,122 @@ export function RaceDetailTemplate({
if (error || !viewData || !viewData.race) {
return (
<Container size="md" py={8}>
<Stack gap={6}>
<Breadcrumbs items={[{ label: 'Races', href: '/races' }, { label: 'Error' }]} />
<Card>
<Stack align="center" gap={4} py={12}>
<Surface variant="muted" rounded="full" padding={4}>
<Icon icon={AlertTriangle} size={8} color="#f59e0b" />
</Surface>
<Box>
<Text weight="medium" color="text-white" block mb={1}>{error instanceof Error ? error.message : error || 'Race not found'}</Text>
<Text size="sm" color="text-gray-500">
The race you&apos;re looking for doesn&apos;t exist or has been removed.
</Text>
</Box>
<Button
variant="secondary"
onClick={onBack}
>
Back to Races
</Button>
</Stack>
</Card>
</Stack>
<Box bg="bg-surface-charcoal" border borderColor="border-outline-steel" p={12} textAlign="center" rounded="xl">
<Stack alignItems="center" gap={4}>
<Text as="h2" size="xl" weight="bold" color="text-white">Race Not Found</Text>
<Text color="text-gray-400">{`The race you're looking for doesn't exist or has been removed.`}</Text>
<Box
as="button"
onClick={onBack}
mt={4}
px={6}
py={2}
bg="bg-primary-accent"
color="text-white"
weight="bold"
rounded="md"
hoverBg="bg-primary-accent"
bgOpacity={0.8}
transition
>
Back to Schedule
</Box>
</Stack>
</Box>
</Container>
);
}
const { race, league, entryList, userResult } = viewData;
const statusConfig = {
scheduled: {
icon: Clock,
variant: 'primary' as const,
label: 'Scheduled',
description: 'This race is scheduled and waiting to start',
},
running: {
icon: PlayCircle,
variant: 'success' as const,
label: 'LIVE NOW',
description: 'This race is currently in progress',
},
completed: {
icon: CheckCircle2,
variant: 'default' as const,
label: 'Completed',
description: 'This race has finished',
},
cancelled: {
icon: XCircle,
variant: 'warning' as const,
label: 'Cancelled',
description: 'This race has been cancelled',
},
};
const config = statusConfig[race.status] || statusConfig.scheduled;
const breadcrumbItems = [
{ label: 'Races', href: '/races' },
...(league ? [{ label: league.name, href: `/leagues/${league.id}` }] : []),
{ label: race.track },
];
return (
<Container size="lg" py={8}>
<Stack gap={6}>
{/* Navigation Row */}
<Stack direction="row" align="center" justify="between">
<Breadcrumbs items={breadcrumbItems} />
<Button
variant="secondary"
onClick={onBack}
size="sm"
icon={<Icon icon={ArrowLeft} size={4} />}
>
Back
</Button>
</Stack>
<Box as="main" minHeight="screen" bg="bg-base-black">
<RaceDetailsHeader
title={race.track}
leagueName={league?.name || 'Official'}
trackName={race.track}
scheduledAt={race.scheduledAt}
status={race.status as SessionStatus}
onBack={onBack}
/>
{/* User Result */}
{userResult && (
<RaceUserResult
{...userResult}
animatedRatingChange={animatedRatingChange}
/>
)}
<Container size="lg" py={8}>
<Stack gap={8}>
{userResult && (
<RaceUserResult
{...userResult}
animatedRatingChange={animatedRatingChange}
/>
)}
{/* Hero Header */}
<RaceHero
track={race.track}
scheduledAt={race.scheduledAt}
car={race.car}
status={race.status}
statusConfig={config}
/>
<Box bg="bg-surface-charcoal" border borderColor="border-outline-steel" p={4}>
<RaceActionBar
status={race.status}
isUserRegistered={viewData.registration.isUserRegistered}
canRegister={viewData.registration.canRegister}
onRegister={onRegister}
onWithdraw={onWithdraw}
onResultsClick={onResultsClick}
onStewardingClick={onStewardingClick}
onFileProtest={onFileProtest}
isAdmin={isOwnerOrAdmin}
onCancel={onCancel}
onReopen={onReopen}
onEndRace={onEndRace}
isLoading={mutationLoading}
/>
</Box>
<Grid cols={12} gap={6}>
<GridItem lgSpan={8} colSpan={12}>
<Stack gap={6}>
<RaceDetailCard
track={race.track}
car={race.car}
sessionType={race.sessionType}
statusLabel={config.label}
statusColor={config.variant === 'success' ? '#10b981' : config.variant === 'primary' ? '#3b82f6' : '#9ca3af'}
/>
<Grid cols={12} gap={6}>
<GridItem lgSpan={8} colSpan={12}>
<Stack gap={6}>
<Box as="section" bg="bg-surface-charcoal" border borderColor="border-outline-steel" overflow="hidden">
<Box p={4} borderBottom borderColor="border-outline-steel" bg="bg-base-black" bgOpacity={0.2}>
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="widest">Entry List</Text>
</Box>
<EntrantsTable
entrants={entryList.map(entry => ({
id: entry.id,
name: entry.name,
carName: race.car,
rating: entry.rating || 0,
status: 'confirmed'
}))}
/>
</Box>
</Stack>
</GridItem>
<RaceEntryList
entries={entryList}
onDriverClick={onDriverClick}
/>
</Stack>
</GridItem>
<GridItem lgSpan={4} colSpan={12}>
<Stack gap={6}>
{league && <LeagueSummaryCard league={league} />}
<TrackConditionsPanel
airTemp="24°C"
trackTemp="31°C"
humidity="45%"
windSpeed="12 km/h NW"
weatherType="Partly Cloudy"
/>
<GridItem lgSpan={4} colSpan={12}>
<Stack gap={6}>
{league && <LeagueSummaryCard league={league} />}
{/* Actions Card */}
<Card>
<Stack gap={4}>
<Text size="xl" weight="bold" color="text-white">Actions</Text>
<Stack gap={3}>
<RaceJoinButton
raceStatus={race.status}
isUserRegistered={viewData.registration.isUserRegistered}
canRegister={viewData.registration.canRegister}
onRegister={onRegister}
onWithdraw={onWithdraw}
onCancel={onCancel}
onReopen={onReopen}
onEndRace={onEndRace}
canReopenRace={viewData.canReopenRace}
isOwnerOrAdmin={isOwnerOrAdmin}
isLoading={mutationLoading}
/>
{race.status === 'completed' && (
<>
<Button variant="primary" fullWidth onClick={onResultsClick} icon={<Icon icon={Trophy} size={4} />}>
View Results
</Button>
{userResult && (
<Button variant="secondary" fullWidth onClick={onFileProtest} icon={<Icon icon={Scale} size={4} />}>
File Protest
</Button>
)}
<Button variant="secondary" fullWidth onClick={onStewardingClick} icon={<Icon icon={Scale} size={4} />}>
Stewarding
</Button>
</>
)}
<Box as="section" bg="bg-surface-charcoal" border borderColor="border-outline-steel" p={4}>
<Text as="h3" size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="widest" mb={4}>Session Info</Text>
<Stack gap={4}>
<Stack gap={1}>
<Text size="xs" color="text-gray-500" uppercase weight="bold">Format</Text>
<Text size="sm" color="text-white">{race.sessionType}</Text>
</Stack>
<Stack gap={1}>
<Text size="xs" color="text-gray-500" uppercase weight="bold">Car Class</Text>
<Text size="sm" color="text-white">{race.car}</Text>
</Stack>
</Stack>
</Stack>
</Card>
{/* Status Info */}
<InfoBox
icon={config.icon}
title={config.label}
description={config.description}
variant={config.variant}
/>
</Stack>
</GridItem>
</Grid>
</Stack>
</Container>
</Box>
</Stack>
</GridItem>
</Grid>
</Stack>
</Container>
</Box>
);
}

View File

@@ -1,22 +1,17 @@
'use client';
import React from 'react';
import { Breadcrumbs } from '@/ui/Breadcrumbs';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
import { ArrowLeft, Trophy, Zap, type LucideIcon } from 'lucide-react';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { Box } from '@/ui/Box';
import { Trophy, Zap, AlertTriangle, type LucideIcon } from 'lucide-react';
import type { RaceResultsViewData } from '@/lib/view-data/races/RaceResultsViewData';
import { RaceResultRow } from '@/components/races/RaceResultRow';
import { RacePenaltyRow } from '@/ui/RacePenaltyRowWrapper';
import { RaceResultsTable } from '@/ui/RaceResultsTable';
import { RaceDetailsHeader } from '@/components/races/RaceDetailsHeader';
export interface RaceResultsTemplateProps {
viewData: RaceResultsViewData;
@@ -40,15 +35,9 @@ export function RaceResultsTemplate({
isLoading,
error,
onBack,
onImportResults,
importing,
importSuccess,
importError,
}: RaceResultsTemplateProps) {
const formatDate = (date: string) => {
return DateDisplay.formatFull(date);
};
const formatTime = (ms: number) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
@@ -56,17 +45,10 @@ export function RaceResultsTemplate({
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(2, '0')}`;
};
const breadcrumbItems = [
{ label: 'Races', href: '/races' },
...(viewData.leagueName ? [{ label: viewData.leagueName, href: `/leagues/${viewData.leagueName}` }] : []),
...(viewData.raceTrack ? [{ label: viewData.raceTrack, href: `/races/${viewData.raceTrack}` }] : []),
{ label: 'Results' },
];
if (isLoading) {
return (
<Container size="lg" py={12}>
<Stack align="center">
<Stack alignItems="center">
<Text color="text-gray-400">Loading results...</Text>
</Stack>
</Container>
@@ -76,147 +58,123 @@ export function RaceResultsTemplate({
if (error && !viewData.raceTrack) {
return (
<Container size="md" py={12}>
<Card>
<Stack align="center" py={12} gap={4}>
<Box bg="bg-surface-charcoal" border borderColor="border-outline-steel" p={12} textAlign="center" rounded="xl">
<Stack alignItems="center" gap={4}>
<Text color="text-warning-amber">{error?.message || 'Race not found'}</Text>
<Button
variant="secondary"
<Box
as="button"
onClick={onBack}
mt={4}
px={6}
py={2}
bg="bg-primary-accent"
color="text-white"
weight="bold"
rounded="md"
hoverBg="bg-primary-accent"
bgOpacity={0.8}
transition
>
Back to Races
</Button>
Back to Schedule
</Box>
</Stack>
</Card>
</Box>
</Container>
);
}
const hasResults = viewData.results.length > 0;
return (
<Container size="lg" py={8}>
<Stack gap={6}>
<Stack direction="row" align="center" justify="between">
<Breadcrumbs items={breadcrumbItems} />
<Button
variant="secondary"
onClick={onBack}
icon={<Icon icon={ArrowLeft} size={4} />}
>
Back
</Button>
</Stack>
<Box as="main" minHeight="screen" bg="bg-base-black">
<RaceDetailsHeader
title={viewData.raceTrack || 'Unknown Track'}
leagueName={viewData.leagueName || 'Official'}
trackName={viewData.raceTrack || 'Unknown Track'}
scheduledAt={viewData.raceScheduledAt || ''}
status="completed"
onBack={onBack}
/>
{/* Header */}
<Surface variant="muted" rounded="xl" border padding={6} bg="bg-neutral-800/50" borderColor="border-neutral-800">
<Stack direction="row" align="center" gap={4} mb={6}>
<Surface variant="muted" rounded="xl" padding={3} bg="bg-blue-500/20">
<Icon icon={Trophy} size={6} color="#3b82f6" />
</Surface>
<Box>
<Heading level={1}>Race Results</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
{viewData.raceTrack} {viewData.raceScheduledAt ? formatDate(viewData.raceScheduledAt) : ''}
</Text>
<Container size="lg" py={8}>
<Stack gap={8}>
{importSuccess && (
<Box p={4} bg="bg-success-green" bgOpacity={0.1} border borderColor="border-success-green" rounded>
<Text color="text-success-green" weight="bold">Success!</Text>
<Text color="text-success-green" size="sm" block mt={1}>Results imported and standings updated.</Text>
</Box>
</Stack>
)}
{/* Stats */}
<Grid cols={4} gap={4}>
<StatItem label="Drivers" value={viewData.totalDrivers ?? 0} />
<StatItem label="League" value={viewData.leagueName ?? '—'} />
<StatItem label="SOF" value={viewData.raceSOF ?? '—'} icon={Zap} color="text-warning-amber" />
<StatItem label="Fastest Lap" value={viewData.fastestLapTime ? formatTime(viewData.fastestLapTime) : '—'} color="text-performance-green" />
</Grid>
</Surface>
{importError && (
<Box p={4} bg="bg-critical-red" bgOpacity={0.1} border borderColor="border-critical-red" rounded>
<Text color="text-critical-red" weight="bold">Error:</Text>
<Text color="text-critical-red" size="sm" block mt={1}>{importError}</Text>
</Box>
)}
{importSuccess && (
<Surface variant="muted" rounded="lg" border padding={4} bg="bg-green-500/10" borderColor="border-green-500/30">
<Text color="text-performance-green" weight="bold">Success!</Text>
<Text color="text-performance-green" size="sm" block mt={1}>Results imported and standings updated.</Text>
</Surface>
)}
<Grid cols={12} gap={6}>
<GridItem colSpan={12} lgSpan={8}>
<Box as="section" bg="bg-surface-charcoal" border borderColor="border-outline-steel" overflow="hidden">
<Box p={4} borderBottom borderColor="border-outline-steel" bg="bg-base-black" bgOpacity={0.2} display="flex" alignItems="center" gap={2}>
<Icon icon={Trophy} size={4} color="var(--color-primary)" />
<Text as="h2" size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="widest">Final Standings</Text>
</Box>
<RaceResultsTable
results={viewData.results as unknown as never[]}
drivers={[]}
pointsSystem={viewData.pointsSystem as unknown as Record<number, number>}
fastestLapTime={viewData.fastestLapTime}
penalties={viewData.penalties as unknown as never[]}
/>
</Box>
</GridItem>
{importError && (
<Surface variant="muted" rounded="lg" border padding={4} bg="bg-red-500/10" borderColor="border-red-500/30">
<Text color="text-error-red" weight="bold">Error:</Text>
<Text color="text-error-red" size="sm" block mt={1}>{importError}</Text>
</Surface>
)}
<Card>
{hasResults ? (
<Stack gap={6}>
{/* Results Table */}
<Stack gap={2}>
{viewData.results.map((result) => (
<RaceResultRow
key={result.driverId}
result={result as unknown as never}
points={viewData.pointsSystem[result.position.toString()] ?? 0}
/>
))}
</Stack>
{/* Penalties Section */}
{viewData.penalties.length > 0 && (
<Box pt={6} borderTop="1px solid" borderColor="border-neutral-800">
<Box mb={4}>
<Heading level={2}>Penalties</Heading>
</Box>
<Stack gap={2}>
{viewData.penalties.map((penalty, index) => (
<RacePenaltyRow key={index} penalty={penalty as unknown as never} />
))}
<GridItem colSpan={12} lgSpan={4}>
<Stack gap={6}>
<Box as="section" bg="bg-surface-charcoal" border borderColor="border-outline-steel" p={4}>
<Text as="h3" size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="widest" mb={4}>Session Stats</Text>
<Stack gap={4}>
<StatItem label="DRIVERS" value={viewData.totalDrivers ?? 0} />
<StatItem label="SOF" value={viewData.raceSOF ?? '—'} icon={Zap} color="text-warning-amber" />
<StatItem label="FASTEST LAP" value={viewData.fastestLapTime ? formatTime(viewData.fastestLapTime) : '—'} color="text-success-green" />
</Stack>
</Box>
)}
</Stack>
) : (
<Stack gap={6}>
<Box>
<Heading level={2}>Import Results</Heading>
<Text size="sm" color="text-gray-400" block mt={2}>
No results imported. Upload CSV to test the standings system.
</Text>
</Box>
{importing ? (
<Stack align="center" py={8}>
<Text color="text-gray-400">Importing results and updating standings...</Text>
</Stack>
) : (
<Stack gap={4}>
<Text size="sm" color="text-gray-400">
This is a placeholder for the import form. In the actual implementation,
this would render the ImportResultsForm component.
</Text>
<Box>
<Button
variant="primary"
onClick={() => onImportResults([])}
disabled={importing}
>
Import Results (Demo)
</Button>
{viewData.penalties.length > 0 && (
<Box as="section" bg="bg-surface-charcoal" border borderColor="border-outline-steel" p={4}>
<Text as="h3" size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="widest" mb={4}>Penalties</Text>
<Stack gap={2}>
{viewData.penalties.map((penalty, index) => (
<Box key={index} p={3} bg="bg-base-black" bgOpacity={0.5} border borderColor="border-outline-steel">
<Stack direction="row" alignItems="center" gap={3}>
<Icon icon={AlertTriangle} size={4} color="var(--color-critical)" />
<Box>
<Text size="sm" weight="bold" color="text-white" block>{penalty.driverName || 'Unknown Driver'}</Text>
<Text size="xs" color="text-gray-500">{penalty.reason || penalty.type}</Text>
</Box>
</Stack>
</Box>
))}
</Stack>
</Box>
</Stack>
)}
</Stack>
)}
</Card>
</Stack>
</Container>
)}
</Stack>
</GridItem>
</Grid>
</Stack>
</Container>
</Box>
);
}
function StatItem({ label, value, icon, color = 'text-white' }: { label: string, value: string | number, icon?: LucideIcon, color?: string }) {
return (
<Surface variant="muted" rounded="lg" padding={3} bg="bg-neutral-900/60">
<Text size="xs" color="text-gray-500" block mb={1}>{label}</Text>
<Stack direction="row" align="center" gap={1.5}>
{icon && <Icon icon={icon} size={4} color={color === 'text-white' ? '#9ca3af' : color} />}
<Text weight="bold" color={color} size="lg">{value}</Text>
<Box p={4} bg="bg-base-black" bgOpacity={0.5} border borderColor="border-outline-steel">
<Stack gap={1}>
<Stack direction="row" alignItems="center" gap={2}>
{icon && <Icon icon={icon} size={3} color={color === 'text-white' ? '#9ca3af' : color} />}
<Text size="xs" color="text-gray-500" weight="bold" uppercase>{label}</Text>
</Stack>
<Text size="xl" weight="bold" color={color}>{value}</Text>
</Stack>
</Surface>
</Box>
);
}

View File

@@ -1,29 +1,19 @@
'use client';
import React from 'react';
import { Breadcrumbs } from '@/ui/Breadcrumbs';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Box } from '@/ui/Box';
import { Flag, CheckCircle, Gavel, Info } from 'lucide-react';
import { RaceStewardingStats } from '@/ui/RaceStewardingStats';
import { StewardingTabs } from '@/ui/StewardingTabs';
import { ProtestCard } from '@/components/leagues/ProtestCardWrapper';
import { RacePenaltyRow } from '@/ui/RacePenaltyRowWrapper';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Button } from '@/ui/Button';
import { Container } from '@/ui/Container';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
import {
AlertTriangle,
ArrowLeft,
CheckCircle,
Flag,
Gavel,
Scale,
} from 'lucide-react';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { RaceDetailsHeader } from '@/components/races/RaceDetailsHeader';
import type { RaceStewardingViewData } from '@/lib/view-data/races/RaceStewardingViewData';
export type StewardingTab = 'pending' | 'resolved' | 'penalties';
@@ -52,13 +42,13 @@ export function RaceStewardingTemplate({
setActiveTab,
}: RaceStewardingTemplateProps) {
const formatDate = (date: string) => {
return DateDisplay.formatShort(date);
return date; // Simplified for template
};
if (isLoading) {
return (
<Container size="lg" py={12}>
<Stack align="center">
<Stack alignItems="center">
<Text color="text-gray-400">Loading stewarding data...</Text>
</Stack>
</Container>
@@ -68,164 +58,157 @@ export function RaceStewardingTemplate({
if (!viewData?.race) {
return (
<Container size="md" py={12}>
<Card>
<Stack align="center" py={12} gap={4}>
<Surface variant="muted" rounded="full" padding={4}>
<Icon icon={AlertTriangle} size={8} color="#f59e0b" />
</Surface>
<Box textAlign="center">
<Text weight="medium" color="text-white" block mb={1}>Race not found</Text>
<Text size="sm" color="text-gray-500">The race you&apos;re looking for doesn&apos;t exist.</Text>
<Box bg="bg-surface-charcoal" border borderColor="border-outline-steel" p={12} textAlign="center" rounded="xl">
<Stack alignItems="center" gap={4}>
<Text as="h2" size="xl" weight="bold" color="text-white">Race Not Found</Text>
<Text color="text-gray-400">{`The race you're looking for doesn't exist.`}</Text>
<Box
as="button"
onClick={onBack}
mt={4}
px={6}
py={2}
bg="bg-primary-accent"
color="text-white"
weight="bold"
rounded="md"
hoverBg="bg-primary-accent"
bgOpacity={0.8}
transition
>
Back to Schedule
</Box>
<Button variant="secondary" onClick={onBack}>
Back to Races
</Button>
</Stack>
</Card>
</Box>
</Container>
);
}
const breadcrumbItems = [
{ label: 'Races', href: '/races' },
{ label: viewData.race.track, href: `/races/${viewData.race.id}` },
{ label: 'Stewarding' },
];
return (
<Container size="lg" py={8}>
<Stack gap={6}>
{/* Navigation */}
<Stack direction="row" align="center" justify="between">
<Breadcrumbs items={breadcrumbItems} />
<Button
variant="secondary"
onClick={onBack}
icon={<Icon icon={ArrowLeft} size={4} />}
>
Back to Race
</Button>
<Box as="main" minHeight="screen" bg="bg-base-black">
<RaceDetailsHeader
title="Stewarding Dashboard"
leagueName={viewData.race.track}
trackName={viewData.race.track}
scheduledAt={viewData.race.scheduledAt}
status="completed"
onBack={onBack}
/>
<Container size="lg" py={8}>
<Stack gap={8}>
<Grid cols={12} gap={6}>
<GridItem colSpan={12} lgSpan={8}>
<Stack gap={6}>
<StewardingTabs
activeTab={activeTab}
onTabChange={setActiveTab}
pendingCount={viewData.pendingProtests.length}
/>
{activeTab === 'pending' && (
<Stack gap={4}>
{viewData.pendingProtests.length === 0 ? (
<Box bg="bg-surface-charcoal" border borderColor="border-outline-steel" p={12} textAlign="center">
<Stack alignItems="center" gap={4}>
<Icon icon={Flag} size={8} color="var(--color-success)" />
<Box>
<Text weight="semibold" size="lg" color="text-white" block mb={1}>All Clear!</Text>
<Text size="sm" color="text-gray-400">No pending protests to review</Text>
</Box>
</Stack>
</Box>
) : (
viewData.pendingProtests.map((protest) => (
<ProtestCard
key={protest.id}
protest={protest}
protester={viewData.driverMap[protest.protestingDriverId]}
accused={viewData.driverMap[protest.accusedDriverId]}
isAdmin={isAdmin}
onReview={onReviewProtest}
formatDate={formatDate}
/>
))
)}
</Stack>
)}
{activeTab === 'resolved' && (
<Stack gap={4}>
{viewData.resolvedProtests.length === 0 ? (
<Box bg="bg-surface-charcoal" border borderColor="border-outline-steel" p={12} textAlign="center">
<Stack alignItems="center" gap={4}>
<Icon icon={CheckCircle} size={8} color="#525252" />
<Box>
<Text weight="semibold" size="lg" color="text-white" block mb={1}>No Resolved Protests</Text>
<Text size="sm" color="text-gray-400">Resolved protests will appear here</Text>
</Box>
</Stack>
</Box>
) : (
viewData.resolvedProtests.map((protest) => (
<ProtestCard
key={protest.id}
protest={protest}
protester={viewData.driverMap[protest.protestingDriverId]}
accused={viewData.driverMap[protest.accusedDriverId]}
isAdmin={isAdmin}
onReview={onReviewProtest}
formatDate={formatDate}
/>
))
)}
</Stack>
)}
{activeTab === 'penalties' && (
<Stack gap={4}>
{viewData.penalties.length === 0 ? (
<Box bg="bg-surface-charcoal" border borderColor="border-outline-steel" p={12} textAlign="center">
<Stack alignItems="center" gap={4}>
<Icon icon={Gavel} size={8} color="#525252" />
<Box>
<Text weight="semibold" size="lg" color="text-white" block mb={1}>No Penalties</Text>
<Text size="sm" color="text-gray-400">Penalties issued for this race will appear here</Text>
</Box>
</Stack>
</Box>
) : (
viewData.penalties.map((penalty) => (
<RacePenaltyRow
key={penalty.id}
penalty={{
...penalty,
driverName: viewData.driverMap[penalty.driverId]?.name || 'Unknown',
type: penalty.type as 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points'
}}
/>
))
)}
</Stack>
)}
</Stack>
</GridItem>
<GridItem colSpan={12} lgSpan={4}>
<Stack gap={6}>
<Box as="section" bg="bg-surface-charcoal" border borderColor="border-outline-steel" p={4}>
<Box display="flex" alignItems="center" gap={2} mb={4}>
<Icon icon={Info} size={4} color="var(--color-primary)" />
<Text as="h3" size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="widest">Stewarding Stats</Text>
</Box>
<RaceStewardingStats
pendingCount={viewData.pendingCount}
resolvedCount={viewData.resolvedCount}
penaltiesCount={viewData.penaltiesCount}
/>
</Box>
</Stack>
</GridItem>
</Grid>
</Stack>
{/* Header */}
<Surface variant="muted" rounded="xl" border padding={6} bg="bg-gradient-to-r from-neutral-800/50 to-neutral-800/30" borderColor="border-neutral-800">
<Stack direction="row" align="center" gap={4} mb={6}>
<Surface variant="muted" rounded="xl" padding={3} bg="bg-blue-500/20">
<Icon icon={Scale} size={6} color="#3b82f6" />
</Surface>
<Box>
<Heading level={1}>Stewarding</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
{viewData.race.track} {formatDate(viewData.race.scheduledAt)}
</Text>
</Box>
</Stack>
{/* Stats */}
<RaceStewardingStats
pendingCount={viewData.pendingCount}
resolvedCount={viewData.resolvedCount}
penaltiesCount={viewData.penaltiesCount}
/>
</Surface>
{/* Tab Navigation */}
<StewardingTabs
activeTab={activeTab}
onTabChange={setActiveTab}
pendingCount={viewData.pendingProtests.length}
/>
{/* Content */}
{activeTab === 'pending' && (
<Stack gap={4}>
{viewData.pendingProtests.length === 0 ? (
<Card>
<Stack align="center" py={12} gap={4}>
<Surface variant="muted" rounded="full" padding={4}>
<Icon icon={Flag} size={8} color="#10b981" />
</Surface>
<Box textAlign="center">
<Text weight="semibold" size="lg" color="text-white" block mb={1}>All Clear!</Text>
<Text size="sm" color="text-gray-400">No pending protests to review</Text>
</Box>
</Stack>
</Card>
) : (
viewData.pendingProtests.map((protest) => (
<ProtestCard
key={protest.id}
protest={protest}
protester={viewData.driverMap[protest.protestingDriverId]}
accused={viewData.driverMap[protest.accusedDriverId]}
isAdmin={isAdmin}
onReview={onReviewProtest}
formatDate={formatDate}
/>
))
)}
</Stack>
)}
{activeTab === 'resolved' && (
<Stack gap={4}>
{viewData.resolvedProtests.length === 0 ? (
<Card>
<Stack align="center" py={12} gap={4}>
<Surface variant="muted" rounded="full" padding={4}>
<Icon icon={CheckCircle} size={8} color="#525252" />
</Surface>
<Box textAlign="center">
<Text weight="semibold" size="lg" color="text-white" block mb={1}>No Resolved Protests</Text>
<Text size="sm" color="text-gray-400">Resolved protests will appear here</Text>
</Box>
</Stack>
</Card>
) : (
viewData.resolvedProtests.map((protest) => (
<ProtestCard
key={protest.id}
protest={protest}
protester={viewData.driverMap[protest.protestingDriverId]}
accused={viewData.driverMap[protest.accusedDriverId]}
isAdmin={isAdmin}
onReview={onReviewProtest}
formatDate={formatDate}
/>
))
)}
</Stack>
)}
{activeTab === 'penalties' && (
<Stack gap={4}>
{viewData.penalties.length === 0 ? (
<Card>
<Stack align="center" py={12} gap={4}>
<Surface variant="muted" rounded="full" padding={4}>
<Icon icon={Gavel} size={8} color="#525252" />
</Surface>
<Box textAlign="center">
<Text weight="semibold" size="lg" color="text-white" block mb={1}>No Penalties</Text>
<Text size="sm" color="text-gray-400">Penalties issued for this race will appear here</Text>
</Box>
</Stack>
</Card>
) : (
viewData.penalties.map((penalty) => (
<RacePenaltyRow
key={penalty.id}
penalty={{
...penalty,
driverName: viewData.driverMap[penalty.driverId]?.name || 'Unknown',
type: penalty.type as 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points'
}}
/>
))
)}
</Stack>
)}
</Stack>
</Container>
</Container>
</Box>
);
}

View File

@@ -1,26 +1,17 @@
'use client';
import React from 'react';
import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';
import { Breadcrumbs } from '@/ui/Breadcrumbs';
import {
Flag,
SlidersHorizontal,
Calendar,
} from 'lucide-react';
import { RaceFilterModal } from '@/ui/RaceFilterModal';
import { Pagination } from '@/ui/Pagination';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Container } from '@/ui/Container';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
import { Stack } from '@/ui/Stack';
import { Skeleton } from '@/ui/Skeleton';
import { RaceListItem } from '@/components/races/RaceListItemWrapper';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Pagination } from '@/ui/Pagination';
import { RaceFilterModal } from '@/ui/RaceFilterModal';
import { RacesHeader } from '@/components/races/RacesHeader';
import { RaceScheduleTable } from '@/components/races/RaceScheduleTable';
import type { RacesViewData } from '@/lib/view-data/RacesViewData';
import type { SessionStatus } from '@/components/races/SessionStatusBadge';
export type StatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
@@ -66,26 +57,18 @@ export function RacesAllTemplate({
setLeagueFilter,
searchQuery,
setSearchQuery,
showFilters,
setShowFilters,
showFilterModal,
setShowFilterModal,
onRaceClick,
}: RacesAllTemplateProps) {
const breadcrumbItems = [
{ label: 'Races', href: '/races' },
{ label: 'All Races' },
];
if (isLoading) {
return (
<Container size="lg" py={8}>
<Stack gap={6}>
<Skeleton width="8rem" height="1.5rem" />
<Skeleton width="12rem" height="2.5rem" />
<Skeleton width="100%" height="12rem" />
<Stack gap={4}>
{[1, 2, 3, 4, 5].map(i => (
<Skeleton key={i} width="100%" height="6rem" />
<Skeleton key={i} width="100%" height="4rem" />
))}
</Stack>
</Stack>
@@ -94,98 +77,84 @@ export function RacesAllTemplate({
}
return (
<Container size="lg" py={8}>
<Stack gap={6}>
{/* Breadcrumbs */}
<Breadcrumbs items={breadcrumbItems} />
<Box as="main" minHeight="screen" bg="bg-base-black" py={8}>
<Container size="lg">
<Stack gap={8}>
<RacesHeader
totalCount={viewData.totalCount}
scheduledCount={viewData.scheduledCount}
runningCount={viewData.runningCount}
completedCount={viewData.completedCount}
/>
{/* Header */}
<Stack direction="row" align="center" justify="between" wrap gap={4}>
<Box>
<Heading level={1} icon={<Icon icon={Flag} size={6} color="#3b82f6" />}>
All Races
</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
{totalFilteredCount} race{totalFilteredCount !== 1 ? 's' : ''} found
<Box display="flex" justifyContent="between" alignItems="center">
<Text size="sm" color="text-gray-400">
Showing <Text as="span" color="text-white" weight="bold">{totalFilteredCount}</Text> races
</Text>
<Box
as="button"
onClick={() => setShowFilterModal(true)}
px={4}
py={2}
bg="bg-surface-charcoal"
border
borderColor="border-outline-steel"
fontSize="10px"
weight="bold"
uppercase
letterSpacing="wider"
hoverBorderColor="border-primary-accent"
transition
>
Filters
</Box>
</Box>
<Button
variant="secondary"
onClick={() => setShowFilters(!showFilters)}
icon={<Icon icon={SlidersHorizontal} size={4} />}
>
Filters
</Button>
<Box as="section" bg="bg-surface-charcoal" border borderColor="border-outline-steel" overflow="hidden">
{races.length === 0 ? (
<Box p={12} textAlign="center">
<Text color="text-gray-500">No races found matching your criteria.</Text>
</Box>
) : (
<RaceScheduleTable
races={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}
/>
)}
</Box>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalFilteredCount}
itemsPerPage={itemsPerPage}
onPageChange={onPageChange}
/>
<RaceFilterModal
isOpen={showFilterModal}
onClose={() => setShowFilterModal(false)}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
leagueFilter={leagueFilter}
setLeagueFilter={setLeagueFilter}
timeFilter="all"
setTimeFilter={() => {}}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
leagues={viewData.leagues}
showSearch={true}
showTimeFilter={false}
/>
</Stack>
{/* Search & Filters (Simplified for template) */}
{showFilters && (
<Card>
<Stack gap={4}>
<Text size="sm" color="text-gray-400">
Use the filter button to open advanced search and filtering options.
</Text>
<Box>
<Button variant="primary" onClick={() => setShowFilterModal(true)}>
Open Filters
</Button>
</Box>
</Stack>
</Card>
)}
{/* Race List */}
{races.length === 0 ? (
<Card>
<Stack align="center" py={12} gap={4}>
<Surface variant="muted" rounded="full" padding={4}>
<Icon icon={Calendar} size={8} color="#525252" />
</Surface>
<Box textAlign="center">
<Text weight="medium" color="text-white" block mb={1}>No races found</Text>
<Text size="sm" color="text-gray-500">
{viewData.races.length === 0
? 'No races have been scheduled yet'
: 'Try adjusting your search or filters'}
</Text>
</Box>
</Stack>
</Card>
) : (
<Stack gap={3}>
{races.map(race => (
<RaceListItem key={race.id} race={race} onClick={onRaceClick} />
))}
</Stack>
)}
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalFilteredCount}
itemsPerPage={itemsPerPage}
onPageChange={onPageChange}
/>
{/* Filter Modal */}
<RaceFilterModal
isOpen={showFilterModal}
onClose={() => setShowFilterModal(false)}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
leagueFilter={leagueFilter}
setLeagueFilter={setLeagueFilter}
timeFilter="all"
setTimeFilter={() => {}}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
leagues={viewData.leagues}
showSearch={true}
showTimeFilter={false}
/>
</Stack>
</Container>
</Container>
</Box>
);
}

View File

@@ -1,18 +1,20 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Container } from '@/ui/Container';
import { RaceFilterModal } from '@/ui/RaceFilterModal';
import type { RacesViewData } from '@/lib/view-data/RacesViewData';
import { RacePageHeader } from '@/ui/RacePageHeader';
import { LiveRacesBanner } from '@/components/races/LiveRacesBanner';
import { RaceFilterBar } from '@/components/races/RaceFilterBar';
import { RaceList } from '@/components/races/RaceList';
import { RaceSidebar } from '@/components/races/RaceSidebar';
import { Stack } from '@/ui/Stack';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { RaceFilterModal } from '@/ui/RaceFilterModal';
import type { RacesViewData } from '@/lib/view-data/RacesViewData';
import { RacesHeader } from '@/components/races/RacesHeader';
import { LiveRacesBanner } from '@/components/races/LiveRacesBanner';
import { RaceFilterBar } from '@/components/races/RaceFilterBar';
import { RaceScheduleTable } from '@/components/races/RaceScheduleTable';
import { RaceSidebar } from '@/components/races/RaceSidebar';
import type { SessionStatus } from '@/components/races/SessionStatusBadge';
export type TimeFilter = 'all' | 'upcoming' | 'live' | 'past';
export type RaceStatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
@@ -49,10 +51,10 @@ export function RacesTemplate({
setShowFilterModal,
}: RacesTemplateProps) {
return (
<Box as="main">
<Container size="lg" py={8}>
<Box as="main" minHeight="screen" bg="bg-base-black" py={8}>
<Container size="lg">
<Stack gap={8}>
<RacePageHeader
<RacesHeader
totalCount={viewData.totalCount}
scheduledCount={viewData.scheduledCount}
runningCount={viewData.runningCount}
@@ -76,11 +78,22 @@ export function RacesTemplate({
onShowMoreFilters={() => setShowFilterModal(true)}
/>
<RaceList
racesByDate={viewData.racesByDate}
totalCount={viewData.totalCount}
onRaceClick={onRaceClick}
/>
<Box as="section" bg="bg-surface-charcoal" border borderColor="border-outline-steel" overflow="hidden">
<Box p={4} borderBottom borderColor="border-outline-steel" bg="bg-base-black" bgOpacity={0.2}>
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="widest">Race Schedule</Text>
</Box>
<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}
/>
</Box>
</Stack>
</GridItem>

View File

@@ -1,14 +1,15 @@
'use client';
import React from 'react';
import { Card } from '@/ui/Card';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { Select } from '@/ui/Select';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Surface } from '@/ui/Surface';
import { Button } from '@/ui/Button';
import { Shield, UserPlus, UserMinus } from 'lucide-react';
import { Icon } from '@/ui/Icon';
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
import type { MembershipRole } from '@/lib/types/MembershipRole';
import type { LeagueRosterAdminViewData } from '@/lib/view-data/LeagueRosterAdminViewData';
@@ -36,119 +37,131 @@ export function RosterAdminTemplate({
const { joinRequests, members } = viewData;
return (
<Stack gap={6}>
<Card>
<Stack gap={6}>
<Box>
<Heading level={1}>Roster Admin</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
Manage join requests and member roles.
</Text>
</Box>
<Box>
<Stack direction="row" align="center" justify="between" mb={4}>
<Heading level={2}>Pending join requests</Heading>
<Text size="xs" color="text-gray-500">
{pendingCountLabel}
</Text>
</Stack>
{loading ? (
<Text size="sm" color="text-gray-400">Loading</Text>
) : joinRequests.length > 0 ? (
<Stack gap={3}>
{joinRequests.map((req) => (
<Surface
key={req.id}
variant="muted"
rounded="lg"
border
padding={3}
>
<Stack direction="row" align="center" justify="between">
<Box>
<Text weight="medium" color="text-white" block>{req.driver.name}</Text>
<Text size="xs" color="text-gray-400" block mt={1}>{req.requestedAt}</Text>
{req.message && (
<Text size="xs" color="text-gray-500" block mt={1} truncate>{req.message}</Text>
)}
</Box>
<Stack direction="row" gap={2}>
<Button
onClick={() => onApprove(req.id)}
variant="primary"
size="sm"
>
Approve
</Button>
<Button
onClick={() => onReject(req.id)}
variant="secondary"
size="sm"
>
Reject
</Button>
</Stack>
</Stack>
</Surface>
))}
</Stack>
) : (
<Text size="sm" color="text-gray-500">No pending join requests.</Text>
)}
</Box>
<Box pt={6} borderTop borderColor="border-neutral-800">
<Box mb={4}>
<Heading level={2}>Members</Heading>
</Box>
{loading ? (
<Text size="sm" color="text-gray-400">Loading</Text>
) : members.length > 0 ? (
<Stack gap={3}>
{members.map((member) => (
<Surface
key={member.driverId}
variant="muted"
rounded="lg"
border
padding={3}
>
<Stack direction="row" align="center" justify="between" wrap gap={4}>
<Box>
<Text weight="medium" color="text-white" block>{member.driver.name}</Text>
<Text size="xs" color="text-gray-400" block mt={1}>{member.joinedAt}</Text>
</Box>
<Stack direction="row" align="center" gap={3}>
<Box>
<Select
value={member.role}
onChange={(e) => onRoleChange(member.driverId, e.target.value as MembershipRole)}
options={roleOptions.map((role) => ({ value: role, label: role }))}
/>
</Box>
<Button
onClick={() => onRemove(member.driverId)}
variant="secondary"
size="sm"
>
Remove
</Button>
</Stack>
</Stack>
</Surface>
))}
</Stack>
) : (
<Text size="sm" color="text-gray-500">No members found.</Text>
)}
<Stack gap={8}>
{/* Join Requests Section */}
<Box>
<Stack direction="row" align="center" justify="between" mb={4}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={UserPlus} size={4} color="text-primary-blue" />
<Heading level={5} color="text-primary-blue">PENDING JOIN REQUESTS</Heading>
</Stack>
<Box px={2} py={0.5} rounded="md" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20">
<Text size="xs" color="text-primary-blue" weight="bold">{pendingCountLabel}</Text>
</Box>
</Stack>
</Card>
{loading ? (
<Surface variant="dark" border rounded="lg" padding={12} center>
<Text color="text-gray-500">Loading requests...</Text>
</Surface>
) : joinRequests.length > 0 ? (
<Surface variant="dark" border rounded="lg" overflow="hidden">
<Stack gap={0}>
{joinRequests.map((req) => (
<Box key={req.id} p={4} borderBottom borderColor="border-charcoal-outline" hoverBg="bg-white/5" transition>
<Stack direction={{ base: 'col', md: 'row' }} align="center" justify="between" gap={4}>
<Stack gap={1}>
<Text weight="bold" color="text-white">{req.driver.name}</Text>
<Text size="xs" color="text-gray-500">{new Date(req.requestedAt).toLocaleString()}</Text>
{req.message && (
<Text size="sm" color="text-gray-400" mt={1}>&quot;{req.message}&quot;</Text>
)}
</Stack>
<Stack direction="row" gap={2}>
<Button onClick={() => onApprove(req.id)} variant="primary" size="sm">
Approve
</Button>
<Button onClick={() => onReject(req.id)} variant="secondary" size="sm">
Reject
</Button>
</Stack>
</Stack>
</Box>
))}
</Stack>
</Surface>
) : (
<Surface variant="dark" border rounded="lg" padding={8} center>
<Text color="text-gray-500">No pending join requests.</Text>
</Surface>
)}
</Box>
{/* Members Section */}
<Box>
<Stack direction="row" align="center" gap={2} mb={4}>
<Icon icon={Shield} size={4} color="text-performance-green" />
<Heading level={5} color="text-performance-green">ACTIVE ROSTER</Heading>
</Stack>
{loading ? (
<Surface variant="dark" border rounded="lg" padding={12} center>
<Text color="text-gray-500">Loading members...</Text>
</Surface>
) : members.length > 0 ? (
<Surface variant="dark" border rounded="lg" overflow="hidden">
<Table>
<TableHead>
<TableRow>
<TableHeader>Driver</TableHeader>
<TableHeader>Joined</TableHeader>
<TableHeader>Role</TableHeader>
<TableHeader textAlign="right">Actions</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{members.map((member) => (
<TableRow key={member.driverId}>
<TableCell>
<Text weight="bold" color="text-white">{member.driver.name}</Text>
</TableCell>
<TableCell>
<Text size="sm" color="text-gray-400">{new Date(member.joinedAt).toLocaleDateString()}</Text>
</TableCell>
<TableCell>
<Box
as="select"
value={member.role}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => onRoleChange(member.driverId, e.target.value as MembershipRole)}
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
rounded="md"
px={2}
py={1}
fontSize="xs"
weight="bold"
color="text-white"
>
{roleOptions.map((role) => (
<Box as="option" key={role} value={role}>{role.toUpperCase()}</Box>
))}
</Box>
</TableCell>
<TableCell textAlign="right">
<Button
onClick={() => onRemove(member.driverId)}
variant="ghost"
size="sm"
>
<Stack direction="row" align="center" gap={1.5}>
<Icon icon={UserMinus} size={3.5} color="text-error-red" />
<Text size="xs" weight="bold" color="text-error-red">REMOVE</Text>
</Stack>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Surface>
) : (
<Surface variant="dark" border rounded="lg" padding={8} center>
<Text color="text-gray-500">No members found.</Text>
</Surface>
)}
</Box>
</Stack>
);
}

View File

@@ -1,15 +1,9 @@
'use client';
import React from 'react';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Badge } from '@/ui/Badge';
import { Grid } from '@/ui/Grid';
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
import { Surface } from '@/ui/Surface';
import { LeagueRulesPanel } from '@/components/leagues/LeagueRulesPanel';
import type { RulebookViewData } from '@/lib/view-data/leagues/RulebookViewData';
interface RulebookTemplateProps {
@@ -17,104 +11,77 @@ interface RulebookTemplateProps {
}
export function RulebookTemplate({ viewData }: RulebookTemplateProps) {
const rules = [
{
id: 'points',
title: 'Points System',
content: `Points are awarded to the top ${viewData.positionPoints.length} finishers. 1st place receives ${viewData.positionPoints[0]?.points || 0} points.`
},
{
id: 'drops',
title: 'Drop Policy',
content: viewData.hasActiveDropPolicy ? viewData.dropPolicySummary : 'No drop races are active for this season.'
},
{
id: 'platform',
title: 'Platform & Sessions',
content: `Racing on ${viewData.gameName}. Sessions scored: ${viewData.sessionTypes}.`
}
];
if (viewData.hasBonusPoints) {
rules.push({
id: 'bonus',
title: 'Bonus Points',
content: viewData.bonusPoints.join('. ')
});
}
return (
<Stack gap={6}>
{/* Header */}
<Stack direction="row" align="center" justify="between">
<Box>
<Heading level={1}>Rulebook</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>Official rules and regulations</Text>
</Box>
<Badge variant="primary">
{viewData.scoringPresetName || 'Custom Rules'}
</Badge>
</Stack>
{/* Quick Stats */}
<Grid cols={4} gap={4}>
<StatItem label="Platform" value={viewData.gameName} />
<StatItem label="Championships" value={viewData.championshipsCount} />
<StatItem label="Sessions Scored" value={viewData.sessionTypes} />
<StatItem label="Drop Policy" value={viewData.hasActiveDropPolicy ? 'Active' : 'None'} />
</Grid>
{/* Points Table */}
<Card>
<Box mb={4}>
<Heading level={2}>Points System</Heading>
</Box>
<Table>
<TableHead>
<TableRow>
<TableHeader>Position</TableHeader>
<TableHeader>Points</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{viewData.positionPoints.map((point) => (
<TableRow key={point.position}>
<TableCell>
<Text color="text-white">{point.position}</Text>
</TableCell>
<TableCell>
<Text color="text-white">{point.points}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{/* Bonus Points */}
{viewData.hasBonusPoints && (
<Card>
<Box mb={4}>
<Heading level={2}>Bonus Points</Heading>
</Box>
<Stack gap={2}>
{viewData.bonusPoints.map((bonus, idx) => (
<Surface
key={idx}
variant="muted"
rounded="lg"
border
padding={3}
>
<Stack direction="row" align="center" gap={4}>
<Surface variant="muted" rounded="full" padding={1} w="8" h="8" bg="bg-performance-green/10" borderColor="border-performance-green/20" display="flex" alignItems="center" justifyContent="center" border>
<Text color="text-performance-green" weight="bold">+</Text>
</Surface>
<Text size="sm" color="text-gray-300">{bonus}</Text>
</Stack>
</Surface>
))}
</Stack>
</Card>
)}
{/* Drop Policy */}
{viewData.hasActiveDropPolicy && (
<Card>
<Box mb={4}>
<Heading level={2}>Drop Policy</Heading>
</Box>
<Text size="sm" color="text-gray-300">{viewData.dropPolicySummary}</Text>
<Box mt={3}>
<Text size="xs" color="text-gray-500" block>
Drop rules are applied automatically when calculating championship standings.
<Box display="flex" flexDirection="col" gap={8}>
<Box as="header" display="flex" flexDirection="col" gap={2}>
<Box display="flex" alignItems="center" justifyContent="between">
<Text as="h2" size="xl" weight="bold" color="text-white" uppercase letterSpacing="tight">Rulebook</Text>
<Box px={2} py={1} bg="blue-500/10" border borderColor="blue-500/20">
<Text size="xs" weight="bold" color="text-blue-500" uppercase letterSpacing="widest">
{viewData.scoringPresetName || 'Custom Rules'}
</Text>
</Box>
</Card>
)}
</Stack>
);
}
</Box>
<Text size="sm" color="text-zinc-500">Official rules and regulations for this championship.</Text>
</Box>
function StatItem({ label, value }: { label: string, value: string | number }) {
return (
<Surface variant="muted" rounded="lg" border padding={4} bg="bg-neutral-800" borderColor="border-neutral-800">
<Text size="xs" color="text-gray-500" uppercase letterSpacing="0.05em" block mb={1}>{label}</Text>
<Text weight="semibold" color="text-white" size="lg">{value}</Text>
</Surface>
<LeagueRulesPanel rules={rules} />
<Box as="section" mt={8}>
<Text as="h3" size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest" mb={4} block>Points Classification</Text>
<Box overflow="hidden" border borderColor="zinc-800" bg="zinc-900/50">
<Box as="table" w="full" textAlign="left">
<Box as="thead">
<Box as="tr" borderBottom borderColor="zinc-800" bg="zinc-900/80">
<Box as="th" px={4} py={2}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="wider">Position</Text>
</Box>
<Box as="th" px={4} py={2} textAlign="right">
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="wider">Points</Text>
</Box>
</Box>
</Box>
<Box as="tbody">
{viewData.positionPoints.map((point) => (
<Box as="tr" key={point.position} hoverBg="zinc-800/50" transition>
<Box as="td" px={4} py={2}>
<Text size="sm" color="text-zinc-400" font="mono">{point.position}</Text>
</Box>
<Box as="td" px={4} py={2} textAlign="right">
<Text size="sm" weight="bold" color="text-white">{point.points}</Text>
</Box>
</Box>
))}
</Box>
</Box>
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Surface } from '@/ui/Surface';
import { Glow } from '@/ui/Glow';
import { Stack } from '@/ui/Stack';
import { ServerErrorPanel } from '@/components/errors/ServerErrorPanel';
import { RecoveryActions } from '@/components/errors/RecoveryActions';
import { ErrorDetails } from '@/components/errors/ErrorDetails';
export interface ServerErrorViewData {
error: Error & { digest?: string };
incidentId?: string;
}
interface ServerErrorTemplateProps {
viewData: ServerErrorViewData;
onRetry: () => void;
onHome: () => void;
}
/**
* ServerErrorTemplate
*
* Template for the 500 error page.
* Composes semantic error components into an "instrument-grade" layout.
*/
export function ServerErrorTemplate({ viewData, onRetry, onHome }: ServerErrorTemplateProps) {
return (
<Box
as="main"
minHeight="screen"
fullWidth
display="flex"
alignItems="center"
justifyContent="center"
bg="bg-deep-graphite"
position="relative"
overflow="hidden"
px={6}
>
{/* Background Accents */}
<Glow color="primary" size="xl" position="center" opacity={0.05} />
<Surface
variant="glass"
border
rounded="lg"
padding={8}
maxWidth="2xl"
fullWidth
position="relative"
zIndex={10}
shadow="xl"
borderColor="border-white"
bgOpacity={0.05}
>
<Stack gap={8} align="center">
<ServerErrorPanel
message={viewData.error.message}
incidentId={viewData.incidentId || viewData.error.digest}
/>
<RecoveryActions
onRetry={onRetry}
onHome={onHome}
/>
<ErrorDetails
error={viewData.error}
/>
</Stack>
</Surface>
</Box>
);
}

View File

@@ -0,0 +1,216 @@
'use client';
import React from 'react';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { InfoBanner } from '@/ui/InfoBanner';
import { SponsorDashboardHeader } from '@/components/sponsors/SponsorDashboardHeader';
import { BillingSummaryPanel } from '@/components/sponsors/BillingSummaryPanel';
import { SponsorPayoutQueueTable, PayoutItem } from '@/components/sponsors/SponsorPayoutQueueTable';
import {
CreditCard,
Building2,
Download,
Percent,
Receipt,
ExternalLink,
LucideIcon
} from 'lucide-react';
import { siteConfig } from '@/lib/siteConfig';
import type { PaymentMethodDTO, InvoiceDTO } from '@/lib/types/tbd/SponsorBillingDTO';
export interface SponsorBillingViewData {
stats: {
totalSpent: number;
pendingAmount: number;
nextPaymentDate: string;
nextPaymentAmount: number;
averageMonthlySpend: number;
};
paymentMethods: PaymentMethodDTO[];
invoices: InvoiceDTO[];
}
interface SponsorBillingTemplateProps {
viewData: SponsorBillingViewData;
billingStats: Array<{
label: string;
value: string;
subValue?: string;
icon: LucideIcon;
variant: 'success' | 'warning' | 'info' | 'default';
}>;
transactions: Array<{
id: string;
date: string;
amount: number | string;
status: string;
recipient?: string;
description?: string;
invoiceNumber?: string;
}>;
onSetDefaultPaymentMethod: (id: string) => void;
onDownloadInvoice: (id: string) => void;
}
export function SponsorBillingTemplate({
viewData,
billingStats,
transactions,
onSetDefaultPaymentMethod,
onDownloadInvoice
}: SponsorBillingTemplateProps) {
// Map transactions to PayoutItems for the new table
const payoutItems: PayoutItem[] = transactions.map(t => ({
id: t.id,
date: t.date,
amount: typeof t.amount === 'number' ? t.amount.toFixed(2) : t.amount,
status: t.status === 'paid' ? 'completed' : t.status === 'pending' ? 'pending' : 'failed',
recipient: t.recipient || 'GridPilot Platform',
description: t.description || `Invoice ${t.invoiceNumber}`
}));
return (
<Container size="lg" py={8}>
<Stack gap={8}>
<SponsorDashboardHeader
sponsorName="Sponsor"
onRefresh={() => console.log('Refresh')}
/>
<BillingSummaryPanel stats={billingStats} />
<Box display="grid" gridCols={{ base: 1, lg: 12 }} gap={8}>
<Box colSpan={{ base: 1, lg: 8 }}>
<Stack gap={8}>
{/* Billing History */}
<Box>
<Stack direction="row" align="center" justify="between" mb={4}>
<Heading level={3}>Billing History</Heading>
<Button variant="secondary" size="sm" icon={<Icon icon={Download} size={4} />} onClick={() => onDownloadInvoice('all')}>
Export All
</Button>
</Stack>
<Card p={0} overflow="hidden">
<SponsorPayoutQueueTable payouts={payoutItems} />
</Card>
</Box>
{/* Platform Fees & VAT */}
<Box display="grid" gridCols={{ base: 1, md: 2 }} gap={6}>
<Card overflow="hidden">
<Box p={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/10">
<Stack direction="row" align="center" gap={2}>
<Icon icon={Percent} size={4} color="text-primary-blue" />
<Text size="xs" weight="bold" uppercase letterSpacing="wider" color="text-gray-400">
Platform Fee
</Text>
</Stack>
</Box>
<Box p={5}>
<Text size="3xl" weight="bold" color="text-white" block mb={2}>
{siteConfig.fees.platformFeePercent}%
</Text>
<Text size="sm" color="text-gray-400" block>
{siteConfig.fees.description}
</Text>
</Box>
</Card>
<Card overflow="hidden">
<Box p={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/10">
<Stack direction="row" align="center" gap={2}>
<Icon icon={Receipt} size={4} color="text-performance-green" />
<Text size="xs" weight="bold" uppercase letterSpacing="wider" color="text-gray-400">
VAT Information
</Text>
</Stack>
</Box>
<Box p={5}>
<Text size="sm" color="text-gray-400" block mb={4}>
{siteConfig.vat.notice}
</Text>
<Stack gap={2}>
<Box display="flex" justifyContent="between">
<Text size="sm" color="text-gray-500">Standard Rate</Text>
<Text size="sm" color="text-white" weight="medium">{siteConfig.vat.standardRate}%</Text>
</Box>
<Box display="flex" justifyContent="between">
<Text size="sm" color="text-gray-500">B2B Reverse Charge</Text>
<Text size="sm" color="text-performance-green" weight="medium">Available</Text>
</Box>
</Stack>
</Box>
</Card>
</Box>
</Stack>
</Box>
<Box colSpan={{ base: 1, lg: 4 }}>
<Stack gap={6}>
{/* Payment Methods */}
<Card>
<Stack gap={4}>
<Heading level={3}>Payment Methods</Heading>
<Stack gap={3}>
{viewData.paymentMethods.map((method: PaymentMethodDTO) => (
<Box
key={method.id}
p={3}
rounded="lg"
border
borderColor={method.isDefault ? 'border-primary-blue/50' : 'border-charcoal-outline/50'}
bg={method.isDefault ? 'bg-primary-blue/5' : 'bg-iron-gray/5'}
>
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={3}>
<Icon icon={method.type === 'sepa' ? Building2 : CreditCard} size={5} color={method.isDefault ? 'text-primary-blue' : 'text-gray-400'} />
<Box>
<Text size="sm" weight="medium" color="text-white">
{method.brand || 'Bank Account'} {method.last4}
</Text>
{method.isDefault && (
<Text size="xs" color="text-primary-blue" weight="medium" block>Default</Text>
)}
</Box>
</Stack>
{!method.isDefault && (
<Button variant="ghost" size="sm" onClick={() => onSetDefaultPaymentMethod(method.id)}>
Set Default
</Button>
)}
</Stack>
</Box>
))}
</Stack>
<InfoBanner type="info">
<Text size="xs">All payments are securely processed by our payment provider.</Text>
</InfoBanner>
</Stack>
</Card>
{/* Support */}
<Card>
<Stack gap={4}>
<Heading level={3}>Billing Support</Heading>
<Text size="sm" color="text-gray-400">
Need help with an invoice or have questions about your plan?
</Text>
<Button variant="secondary" fullWidth icon={<Icon icon={ExternalLink} size={4} />}>
Contact Support
</Button>
</Stack>
</Card>
</Stack>
</Box>
</Box>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,168 @@
import React from 'react';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { SponsorDashboardHeader } from '@/components/sponsors/SponsorDashboardHeader';
import { BillingSummaryPanel } from '@/components/sponsors/BillingSummaryPanel';
import { SponsorContractCard } from '@/components/sponsors/SponsorContractCard';
import {
Search,
Check,
Clock,
Eye,
BarChart3,
LucideIcon
} from 'lucide-react';
export type SponsorshipType = 'all' | 'leagues' | 'teams' | 'drivers' | 'races' | 'platform';
export type SponsorshipStatus = 'all' | 'active' | 'pending_approval' | 'approved' | 'rejected' | 'expired';
export interface SponsorCampaignsViewData {
sponsorships: Array<{
id: string;
type: string;
status: string;
leagueName: string;
seasonName: string;
tier: string;
pricing: { amount: number; currency: string };
metrics: { impressions: number };
seasonStartDate?: Date;
seasonEndDate?: Date;
}>;
stats: {
total: number;
active: number;
pending: number;
approved: number;
rejected: number;
totalInvestment: number;
totalImpressions: number;
};
}
interface SponsorCampaignsTemplateProps {
viewData: SponsorCampaignsViewData;
filteredSponsorships: SponsorCampaignsViewData['sponsorships'];
typeFilter: SponsorshipType;
setTypeFilter: (type: SponsorshipType) => void;
searchQuery: string;
setSearchQuery: (query: string) => void;
}
export function SponsorCampaignsTemplate({
viewData,
filteredSponsorships,
typeFilter,
setTypeFilter,
searchQuery,
setSearchQuery
}: SponsorCampaignsTemplateProps) {
const billingStats: Array<{
label: string;
value: string | number;
icon: LucideIcon;
variant: 'success' | 'warning' | 'info' | 'default';
}> = [
{
label: 'Active Campaigns',
value: viewData.stats.active,
icon: Check,
variant: 'success',
},
{
label: 'Pending Approval',
value: viewData.stats.pending,
icon: Clock,
variant: viewData.stats.pending > 0 ? 'warning' : 'default',
},
{
label: 'Total Investment',
value: `$${viewData.stats.totalInvestment.toLocaleString()}`,
icon: BarChart3,
variant: 'info',
},
{
label: 'Total Impressions',
value: `${(viewData.stats.totalImpressions / 1000).toFixed(0)}k`,
icon: Eye,
variant: 'default',
},
];
return (
<Container size="lg" py={8}>
<Stack gap={8}>
<SponsorDashboardHeader
sponsorName="Sponsor"
onRefresh={() => console.log('Refresh')}
/>
<BillingSummaryPanel stats={billingStats} />
<Box>
<Stack direction={{ base: 'col', lg: 'row' }} gap={4} mb={6}>
<Box position="relative" flexGrow={1}>
<Box position="absolute" left={3} top="1/2" transform="-translate-y-1/2">
<Icon icon={Search} size={4} color="text-gray-500" />
</Box>
<Box
as="input"
type="text"
placeholder="Search sponsorships..."
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
w="full"
pl={10}
pr={4}
py={2}
rounded="lg"
border
borderColor="border-charcoal-outline"
bg="bg-iron-gray/50"
color="text-white"
outline="none"
focusBorderColor="border-primary-blue"
/>
</Box>
<Stack direction="row" gap={2} overflow="auto" pb={{ base: 2, lg: 0 }}>
{(['all', 'leagues', 'teams', 'drivers'] as const).map((type) => (
<Button
key={type}
variant={typeFilter === type ? 'primary' : 'secondary'}
size="sm"
onClick={() => setTypeFilter(type)}
>
{type.charAt(0).toUpperCase() + type.slice(1)}
</Button>
))}
</Stack>
</Stack>
<Box display="grid" gridCols={{ base: 1, lg: 2 }} gap={4}>
{filteredSponsorships.map((s) => {
return (
<SponsorContractCard
key={s.id}
id={s.id}
type="league"
status={s.status}
title={s.leagueName}
subtitle={s.seasonName}
tier={s.tier}
investment={`$${s.pricing.amount.toLocaleString()}`}
impressions={s.metrics.impressions.toLocaleString()}
startDate={s.seasonStartDate ? new Date(s.seasonStartDate).toLocaleDateString() : undefined}
endDate={s.seasonEndDate ? new Date(s.seasonEndDate).toLocaleDateString() : undefined}
/>
);
})}
</Box>
</Box>
</Stack>
</Container>
);
}

View File

@@ -6,38 +6,32 @@ import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
import { Container } from '@/ui/Container';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { Badge } from '@/ui/Badge';
import { SponsorDashboardHeader } from '@/components/sponsors/SponsorDashboardHeader';
import { SponsorContractCard } from '@/components/sponsors/SponsorContractCard';
import { MetricCard } from '@/components/sponsors/MetricCard';
import { SponsorshipCategoryCard } from '@/components/sponsors/SponsorshipCategoryCard';
import { ActivityItem } from '@/components/sponsors/ActivityItem';
import { RenewalAlert } from '@/components/sponsors/RenewalAlert';
import { BillingSummaryPanel } from '@/components/sponsors/BillingSummaryPanel';
import { SponsorActivityPanel, Activity } from '@/components/sponsors/SponsorActivityPanel';
import {
Eye,
Users,
Trophy,
TrendingUp,
DollarSign,
Target,
ExternalLink,
Car,
Flag,
Megaphone,
ChevronRight,
Plus,
Bell,
Settings,
CreditCard,
FileText,
RefreshCw,
BarChart3,
Calendar
Clock,
Car,
Flag,
Megaphone,
LucideIcon
} from 'lucide-react';
import type { SponsorDashboardViewData } from '@/lib/view-data/SponsorDashboardViewData';
import { routes } from '@/lib/routing/RouteConfig';
@@ -49,43 +43,59 @@ interface SponsorDashboardTemplateProps {
export function SponsorDashboardTemplate({ viewData }: SponsorDashboardTemplateProps) {
const categoryData = viewData.categoryData;
const billingStats: Array<{
label: string;
value: string | number;
icon: LucideIcon;
variant: 'info' | 'success' | 'default' | 'warning';
}> = [
{
label: 'Total Investment',
value: viewData.formattedTotalInvestment,
icon: DollarSign,
variant: 'info',
},
{
label: 'Active Sponsorships',
value: viewData.activeSponsorships,
icon: Trophy,
variant: 'success',
},
{
label: 'Cost per 1K Views',
value: viewData.costPerThousandViews,
icon: Eye,
variant: 'default',
},
{
label: 'Upcoming Renewals',
value: viewData.upcomingRenewals.length,
icon: Bell,
variant: viewData.upcomingRenewals.length > 0 ? 'warning' : 'default',
},
];
const activities: Activity[] = viewData.recentActivity.map(a => ({
id: a.id,
type: 'sponsorship_approved', // Mapping logic would go here
title: a.message,
description: a.formattedImpressions ? `${a.formattedImpressions} impressions` : '',
timestamp: a.time,
icon: Clock,
color: a.typeColor || 'text-primary-blue',
}));
return (
<Container size="lg" py={8}>
<Stack gap={8}>
{/* Header */}
<Stack direction="row" align="center" justify="between" wrap gap={4}>
<Box>
<Heading level={2}>Sponsor Dashboard</Heading>
<Text color="text-gray-400" block mt={1}>Welcome back, {viewData.sponsorName}</Text>
</Box>
<Stack direction="row" align="center" gap={3}>
{/* Time Range Selector */}
<Surface variant="muted" rounded="lg" padding={1}>
<Stack direction="row" align="center">
{(['7d', '30d', '90d', 'all'] as const).map((range) => (
<Button
key={range}
variant="ghost"
size="sm"
>
{range === 'all' ? 'All' : range}
</Button>
))}
</Stack>
</Surface>
<SponsorDashboardHeader
sponsorName={viewData.sponsorName}
onRefresh={() => console.log('Refresh')}
/>
<Button variant="secondary">
<Icon icon={RefreshCw} size={4} />
</Button>
<Box>
<Link href={routes.sponsor.settings} variant="ghost">
<Button variant="secondary">
<Icon icon={Settings} size={4} />
</Button>
</Link>
</Box>
</Stack>
</Stack>
{/* Billing Summary */}
<BillingSummaryPanel stats={billingStats} />
{/* Key Metrics */}
<Grid cols={4} gap={4}>
@@ -120,167 +130,135 @@ export function SponsorDashboardTemplate({ viewData }: SponsorDashboardTemplateP
/>
</Grid>
{/* Sponsorship Categories */}
<Box>
<Stack direction="row" align="center" justify="between" mb={4}>
<Heading level={3}>Your Sponsorships</Heading>
<Box>
<Link href={routes.sponsor.campaigns} variant="primary">
<Button variant="secondary" size="sm" icon={<Icon icon={ChevronRight} size={4} />}>
View All
</Button>
</Link>
</Box>
</Stack>
<Grid cols={5} gap={4}>
<SponsorshipCategoryCard
icon={Trophy}
title="Leagues"
count={categoryData.leagues.count}
impressions={categoryData.leagues.impressions}
color="#3b82f6"
href="/sponsor/campaigns?type=leagues"
/>
<SponsorshipCategoryCard
icon={Users}
title="Teams"
count={categoryData.teams.count}
impressions={categoryData.teams.impressions}
color="#a855f7"
href="/sponsor/campaigns?type=teams"
/>
<SponsorshipCategoryCard
icon={Car}
title="Drivers"
count={categoryData.drivers.count}
impressions={categoryData.drivers.impressions}
color="#10b981"
href="/sponsor/campaigns?type=drivers"
/>
<SponsorshipCategoryCard
icon={Flag}
title="Races"
count={categoryData.races.count}
impressions={categoryData.races.impressions}
color="#f59e0b"
href="/sponsor/campaigns?type=races"
/>
<SponsorshipCategoryCard
icon={Megaphone}
title="Platform Ads"
count={categoryData.platform.count}
impressions={categoryData.platform.impressions}
color="#ef4444"
href="/sponsor/campaigns?type=platform"
/>
</Grid>
</Box>
{/* Main Content Grid */}
<Grid cols={12} gap={6}>
<GridItem colSpan={12} lgSpan={8}>
<Stack gap={6}>
{/* Top Performing Sponsorships */}
<Card p={0}>
<Box p={4} borderBottom borderColor="border-neutral-800">
<Stack direction="row" align="center" justify="between">
<Heading level={3}>Top Performing</Heading>
<Box>
<Link href={routes.public.leagues} variant="primary">
<Button variant="secondary" size="sm" icon={<Icon icon={Plus} size={4} />}>
Find More
</Button>
</Link>
</Box>
</Stack>
</Box>
<Box p={4}>
<Surface variant="muted" rounded="lg" padding={4}>
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={4}>
<Badge variant="primary">Main</Badge>
<Box>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Trophy} size={4} color="#737373" />
<Text weight="medium" color="text-white">Sample League</Text>
</Stack>
<Text size="sm" color="text-gray-500" block mt={1}>Sample details</Text>
</Box>
</Stack>
<Stack direction="row" align="center" gap={4}>
<Box textAlign="right">
<Text weight="semibold" color="text-white" block>1.2k</Text>
<Text size="xs" color="text-gray-500">impressions</Text>
</Box>
<Button variant="secondary" size="sm">
<Icon icon={ExternalLink} size={3} />
</Button>
</Stack>
</Stack>
</Surface>
</Box>
</Card>
{/* Sponsorship Categories */}
<Box>
<Stack direction="row" align="center" justify="between" mb={4}>
<Heading level={3}>Your Sponsorships</Heading>
<Link href={routes.sponsor.campaigns}>
<Button variant="secondary" size="sm" icon={<Icon icon={ChevronRight} size={4} />}>
View All
</Button>
</Link>
</Stack>
{/* Upcoming Events */}
<Card p={0}>
<Box p={4} borderBottom borderColor="border-neutral-800">
<Heading level={3} icon={<Icon icon={Calendar} size={5} color="#f59e0b" />}>
Upcoming Sponsored Events
</Heading>
</Box>
<Box p={4}>
<Stack align="center" gap={2} py={8}>
<Icon icon={Calendar} size={8} color="#737373" />
<Text color="text-gray-400">No upcoming sponsored events</Text>
</Stack>
</Box>
</Card>
<Grid cols={5} gap={4}>
<SponsorshipCategoryCard
icon={Trophy}
title="Leagues"
count={categoryData.leagues.count}
impressions={categoryData.leagues.impressions}
color="#3b82f6"
href="/sponsor/campaigns?type=leagues"
/>
<SponsorshipCategoryCard
icon={Users}
title="Teams"
count={categoryData.teams.count}
impressions={categoryData.teams.impressions}
color="#a855f7"
href="/sponsor/campaigns?type=teams"
/>
<SponsorshipCategoryCard
icon={Car}
title="Drivers"
count={categoryData.drivers.count}
impressions={categoryData.drivers.impressions}
color="#10b981"
href="/sponsor/campaigns?type=drivers"
/>
<SponsorshipCategoryCard
icon={Flag}
title="Races"
count={categoryData.races.count}
impressions={categoryData.races.impressions}
color="#f59e0b"
href="/sponsor/campaigns?type=races"
/>
<SponsorshipCategoryCard
icon={Megaphone}
title="Platform Ads"
count={categoryData.platform.count}
impressions={categoryData.platform.impressions}
color="#ef4444"
href="/sponsor/campaigns?type=platform"
/>
</Grid>
</Box>
{/* Top Performing Sponsorships */}
<Box>
<Stack direction="row" align="center" justify="between" mb={4}>
<Heading level={3}>Top Performing</Heading>
<Link href={routes.public.leagues}>
<Button variant="secondary" size="sm" icon={<Icon icon={Plus} size={4} />}>
Find More
</Button>
</Link>
</Stack>
<Grid cols={2} gap={4}>
<SponsorContractCard
id="sample-1"
type="league"
status="active"
title="Sample League"
subtitle="Season 5 • GT3 Series"
tier="Main Sponsor"
investment="$2,500"
impressions="1.2M"
startDate="2025-10-01"
endDate="2026-02-15"
/>
<SponsorContractCard
id="sample-2"
type="team"
status="active"
title="Apex Racing Team"
subtitle="Endurance Championship"
tier="Secondary Sponsor"
investment="$1,200"
impressions="450k"
startDate="2025-11-15"
endDate="2026-03-20"
/>
</Grid>
</Box>
</Stack>
</GridItem>
<GridItem colSpan={12} lgSpan={4}>
<Stack gap={6}>
{/* Recent Activity */}
<SponsorActivityPanel activities={activities} />
{/* Quick Actions */}
<Card>
<Stack gap={4}>
<Heading level={3}>Quick Actions</Heading>
<Stack gap={2}>
<Box>
<Link href={routes.public.leagues} variant="ghost">
<Button variant="secondary" fullWidth icon={<Icon icon={Target} size={4} />}>
Find Leagues to Sponsor
</Button>
</Link>
</Box>
<Box>
<Link href={routes.public.teams} variant="ghost">
<Button variant="secondary" fullWidth icon={<Icon icon={Users} size={4} />}>
Browse Teams
</Button>
</Link>
</Box>
<Box>
<Link href={routes.public.drivers} variant="ghost">
<Button variant="secondary" fullWidth icon={<Icon icon={Car} size={4} />}>
Discover Drivers
</Button>
</Link>
</Box>
<Box>
<Link href={routes.sponsor.billing} variant="ghost">
<Button variant="secondary" fullWidth icon={<Icon icon={CreditCard} size={4} />}>
Manage Billing
</Button>
</Link>
</Box>
<Box>
<Link href={routes.sponsor.campaigns} variant="ghost">
<Button variant="secondary" fullWidth icon={<Icon icon={BarChart3} size={4} />}>
View Analytics
</Button>
</Link>
</Box>
<Link href={routes.public.leagues}>
<Button variant="secondary" fullWidth icon={<Icon icon={Trophy} size={4} />}>
Find Leagues to Sponsor
</Button>
</Link>
<Link href={routes.public.teams}>
<Button variant="secondary" fullWidth icon={<Icon icon={Users} size={4} />}>
Browse Teams
</Button>
</Link>
<Link href={routes.public.drivers}>
<Button variant="secondary" fullWidth icon={<Icon icon={Car} size={4} />}>
Discover Drivers
</Button>
</Link>
<Link href={routes.sponsor.billing}>
<Button variant="secondary" fullWidth icon={<Icon icon={DollarSign} size={4} />}>
Manage Billing
</Button>
</Link>
</Stack>
</Stack>
</Card>
@@ -300,56 +278,6 @@ export function SponsorDashboardTemplate({ viewData }: SponsorDashboardTemplateP
</Stack>
</Card>
)}
{/* Recent Activity */}
<Card>
<Stack gap={4}>
<Heading level={3}>Recent Activity</Heading>
<Box>
{viewData.recentActivity.map((activity) => (
<ActivityItem key={activity.id} activity={activity} />
))}
</Box>
</Stack>
</Card>
{/* Investment Summary */}
<Card>
<Stack gap={4}>
<Heading level={3} icon={<Icon icon={FileText} size={5} color="#3b82f6" />}>
Investment Summary
</Heading>
<Stack gap={3}>
<Stack direction="row" align="center" justify="between">
<Text color="text-gray-400">Active Sponsorships</Text>
<Text weight="medium" color="text-white">{viewData.activeSponsorships}</Text>
</Stack>
<Stack direction="row" align="center" justify="between">
<Text color="text-gray-400">Total Investment</Text>
<Text weight="medium" color="text-white">{viewData.formattedTotalInvestment}</Text>
</Stack>
<Stack direction="row" align="center" justify="between">
<Text color="text-gray-400">Cost per 1K Views</Text>
<Text weight="medium" color="text-performance-green">
{viewData.costPerThousandViews}
</Text>
</Stack>
<Stack direction="row" align="center" justify="between">
<Text color="text-gray-400">Next Invoice</Text>
<Text weight="medium" color="text-white">Jan 1, 2026</Text>
</Stack>
<Box pt={3} borderTop borderColor="border-neutral-800">
<Box>
<Link href={routes.sponsor.billing} variant="ghost">
<Button variant="secondary" fullWidth size="sm" icon={<Icon icon={CreditCard} size={4} />}>
View Billing Details
</Button>
</Link>
</Box>
</Box>
</Stack>
</Stack>
</Card>
</Stack>
</GridItem>
</Grid>

View File

@@ -1,14 +1,16 @@
'use client';
import React from 'react';
import { SponsorDashboardHeader } from '@/components/sponsors/SponsorDashboardHeader';
import { PricingTableShell, PricingTier } from '@/components/sponsors/PricingTableShell';
import { BillingSummaryPanel } from '@/components/sponsors/BillingSummaryPanel';
import { SponsorBrandingPreview } from '@/components/sponsors/SponsorBrandingPreview';
import { SponsorStatusChip } from '@/components/sponsors/SponsorStatusChip';
import {
Trophy,
Users,
Calendar,
Eye,
TrendingUp,
ExternalLink,
Star,
Flag,
BarChart3,
Megaphone,
@@ -28,9 +30,6 @@ import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { Badge } from '@/ui/Badge';
import { SponsorTierCard } from '@/components/sponsors/SponsorTierCard';
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
import { siteConfig } from '@/lib/siteConfig';
import { routes } from '@/lib/routing/RouteConfig';
@@ -118,6 +117,60 @@ export function SponsorLeagueDetailTemplate({
}: SponsorLeagueDetailTemplateProps) {
const league = viewData.league;
const billingStats: Array<{
label: string;
value: string | number;
icon: LucideIcon;
variant: 'info' | 'success' | 'warning' | 'default';
}> = [
{
label: 'Total Views',
value: league.formattedTotalImpressions,
icon: Eye,
variant: 'info',
},
{
label: 'Avg/Race',
value: league.formattedAvgViewsPerRace,
icon: TrendingUp,
variant: 'success',
},
{
label: 'Engagement',
value: `${league.engagement}%`,
icon: BarChart3,
variant: 'warning',
},
{
label: 'Races Left',
value: league.racesLeft,
icon: Calendar,
variant: 'default',
},
];
const pricingTiers: PricingTier[] = [
{
id: 'main',
name: 'Main Sponsor',
price: league.sponsorSlots.main.price,
period: 'Season',
description: 'Exclusive primary branding across all league assets.',
features: league.sponsorSlots.main.benefits,
available: league.sponsorSlots.main.available,
isPopular: true,
},
{
id: 'secondary',
name: 'Secondary Sponsor',
price: league.sponsorSlots.secondary.price,
period: 'Season',
description: 'Supporting branding on cars and broadcast overlays.',
features: league.sponsorSlots.secondary.benefits,
available: league.sponsorSlots.secondary.available > 0,
}
];
return (
<Container size="lg" py={8}>
<Stack gap={8}>
@@ -137,49 +190,13 @@ export function SponsorLeagueDetailTemplate({
</Box>
{/* Header */}
<Stack direction="row" align="start" justify="between" wrap gap={6}>
<Box flexGrow={1}>
<Stack direction="row" align="center" gap={3} mb={2}>
<Badge variant="primary"> {league.tier}</Badge>
<Badge variant="success">Active Season</Badge>
<Surface variant="muted" rounded="lg" padding={1} bg="bg-neutral-800/50" px={2}>
<Stack direction="row" align="center" gap={1}>
<Icon icon={Star} size={3.5} color="#facc15" />
<Text size="sm" weight="medium" color="text-white">{league.rating}</Text>
</Stack>
</Surface>
</Stack>
<Heading level={1}>{league.name}</Heading>
<Text color="text-gray-400" block mt={2}>
{league.game} {league.season} {league.completedRaces}/{league.races} races completed
</Text>
<Text color="text-gray-400" block mt={4} maxWidth="42rem">
{league.description}
</Text>
</Box>
<Stack direction="row" gap={3}>
<Link href={routes.league.detail(league.id)}>
<Button variant="secondary" icon={<Icon icon={ExternalLink} size={4} />}>
View League
</Button>
</Link>
{(league.sponsorSlots.main.available || league.sponsorSlots.secondary.available > 0) && (
<Button variant="primary" onClick={() => setActiveTab('sponsor')} icon={<Icon icon={Megaphone} size={4} />}>
Become a Sponsor
</Button>
)}
</Stack>
</Stack>
<SponsorDashboardHeader
sponsorName="Sponsor"
onRefresh={() => console.log('Refresh')}
/>
{/* Quick Stats */}
<Grid cols={5} gap={4}>
<StatCard icon={Eye} label="Total Views" value={league.formattedTotalImpressions} color="#3b82f6" />
<StatCard icon={TrendingUp} label="Avg/Race" value={league.formattedAvgViewsPerRace} color="#10b981" />
<StatCard icon={Users} label="Drivers" value={league.drivers} color="#a855f7" />
<StatCard icon={BarChart3} label="Engagement" value={`${league.engagement}%`} color="#f59e0b" />
<StatCard icon={Calendar} label="Races Left" value={league.racesLeft} color="#ef4444" />
</Grid>
<BillingSummaryPanel stats={billingStats} />
{/* Tabs */}
<Box borderBottom borderColor="border-neutral-800">
@@ -321,11 +338,11 @@ export function SponsorLeagueDetailTemplate({
<Box>
{race.status === 'completed' ? (
<Box textAlign="right">
<Text weight="semibold" color="text-white" block>{NumberDisplay.format(race.views)}</Text>
<Text weight="semibold" color="text-white" block>{race.views.toLocaleString()}</Text>
<Text size="xs" color="text-gray-500">views</Text>
</Box>
) : (
<Badge variant="warning">Upcoming</Badge>
<SponsorStatusChip status="pending" label="Upcoming" />
)}
</Box>
</Stack>
@@ -336,85 +353,65 @@ export function SponsorLeagueDetailTemplate({
)}
{activeTab === 'sponsor' && (
<Stack gap={6}>
<Grid cols={2} gap={6}>
<SponsorTierCard
type="main"
available={league.sponsorSlots.main.available}
price={league.sponsorSlots.main.price}
benefits={league.sponsorSlots.main.benefits}
isSelected={selectedTier === 'main'}
onClick={() => setSelectedTier('main')}
<Grid cols={12} gap={6}>
<GridItem colSpan={12} lgSpan={8}>
<PricingTableShell
title="Sponsorship Tiers"
tiers={pricingTiers}
selectedId={selectedTier}
onSelect={(id) => setSelectedTier(id as 'main' | 'secondary')}
/>
<SponsorTierCard
type="secondary"
available={league.sponsorSlots.secondary.available > 0}
availableCount={league.sponsorSlots.secondary.available}
totalCount={league.sponsorSlots.secondary.total}
price={league.sponsorSlots.secondary.price}
benefits={league.sponsorSlots.secondary.benefits}
isSelected={selectedTier === 'secondary'}
onClick={() => setSelectedTier('secondary')}
/>
</Grid>
<Card>
<Box mb={4}>
<Heading level={2} icon={<Icon icon={CreditCard} size={5} color="#3b82f6" />}>
Sponsorship Summary
</Heading>
</Box>
<Stack gap={3} mb={6}>
<InfoRow label="Selected Tier" value={`${selectedTier.charAt(0).toUpperCase() + selectedTier.slice(1)} Sponsor`} />
<InfoRow label="Season Price" value={`$${selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price}`} />
<InfoRow label={`Platform Fee (${siteConfig.fees.platformFeePercent}%)`} value={`$${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * siteConfig.fees.platformFeePercent / 100).toFixed(2)}`} />
<Box pt={4} borderTop borderColor="border-neutral-800">
<Stack direction="row" align="center" justify="between">
<Text weight="semibold" color="text-white">Total (excl. VAT)</Text>
<Text size="xl" weight="bold" color="text-white">
${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * (1 + siteConfig.fees.platformFeePercent / 100)).toFixed(2)}
</Text>
</GridItem>
<GridItem colSpan={12} lgSpan={4}>
<Stack gap={6}>
<SponsorBrandingPreview
name="Your Brand"
/>
<Card>
<Box mb={4}>
<Heading level={2} icon={<Icon icon={CreditCard} size={5} color="#3b82f6" />}>
Sponsorship Summary
</Heading>
</Box>
<Stack gap={3} mb={6}>
<InfoRow label="Selected Tier" value={`${selectedTier.charAt(0).toUpperCase() + selectedTier.slice(1)} Sponsor`} />
<InfoRow label="Season Price" value={`$${selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price}`} />
<InfoRow label={`Platform Fee (${siteConfig.fees.platformFeePercent}%)`} value={`$${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * siteConfig.fees.platformFeePercent / 100).toFixed(2)}`} />
<Box pt={4} borderTop borderColor="border-neutral-800">
<Stack direction="row" align="center" justify="between">
<Text weight="semibold" color="text-white">Total (excl. VAT)</Text>
<Text size="xl" weight="bold" color="text-white">
${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * (1 + siteConfig.fees.platformFeePercent / 100)).toFixed(2)}
</Text>
</Stack>
</Box>
</Stack>
</Box>
</Stack>
<Text size="xs" color="text-gray-500" block mb={4}>
{siteConfig.vat.notice}
</Text>
<Text size="xs" color="text-gray-500" block mb={4}>
{siteConfig.vat.notice}
</Text>
<Stack direction="row" gap={3}>
<Button variant="primary" fullWidth icon={<Icon icon={Megaphone} size={4} />}>
Request Sponsorship
</Button>
<Button variant="secondary" icon={<Icon icon={FileText} size={4} />}>
Download Info Pack
</Button>
<Stack direction="row" gap={3}>
<Button variant="primary" fullWidth icon={<Icon icon={Megaphone} size={4} />}>
Request Sponsorship
</Button>
<Button variant="secondary" icon={<Icon icon={FileText} size={4} />}>
Download Info Pack
</Button>
</Stack>
</Card>
</Stack>
</Card>
</Stack>
</GridItem>
</Grid>
)}
</Stack>
</Container>
);
}
function StatCard({ icon, label, value, color }: { icon: LucideIcon, label: string, value: string | number, color: string }) {
return (
<Card>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} bg={`${color}1A`}>
<Icon icon={icon} size={5} color={color} />
</Surface>
<Box>
<Text size="xl" weight="bold" color="text-white" block>{value}</Text>
<Text size="xs" color="text-gray-500" block>{label}</Text>
</Box>
</Stack>
</Card>
);
}
function InfoRow({ label, value, color = 'text-white', last }: { label: string, value: string | number, color?: string, last?: boolean }) {
return (
<Box py={2} borderBottom={!last} borderColor="border-neutral-800/50">

View File

@@ -22,6 +22,7 @@ import {
} from 'lucide-react';
import { siteConfig } from '@/lib/siteConfig';
import { routes } from '@/lib/routing/RouteConfig';
import { SponsorDashboardHeader } from '@/components/sponsors/SponsorDashboardHeader';
import { AvailableLeagueCard } from '@/components/sponsors/AvailableLeagueCard';
import { Input } from '@/ui/Input';
@@ -47,7 +48,7 @@ export type SortOption = 'rating' | 'drivers' | 'price' | 'views';
export type TierFilter = 'all' | 'premium' | 'standard' | 'starter';
export type AvailabilityFilter = 'all' | 'main' | 'secondary';
interface SponsorLeaguesViewData {
export interface SponsorLeaguesViewData {
leagues: AvailableLeague[];
stats: {
total: number;
@@ -88,14 +89,10 @@ export function SponsorLeaguesTemplate({
</Box>
{/* Header */}
<Box>
<Heading level={1} icon={<Icon icon={Trophy} size={7} color="#3b82f6" />}>
League Sponsorship Marketplace
</Heading>
<Text color="text-gray-400" block mt={2}>
Discover racing leagues looking for sponsors. All prices shown exclude VAT.
</Text>
</Box>
<SponsorDashboardHeader
sponsorName="Sponsor"
onRefresh={() => console.log('Refresh')}
/>
{/* Stats Overview */}
<Grid cols={5} gap={4}>

View File

@@ -0,0 +1,208 @@
import React from 'react';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Input } from '@/ui/Input';
import { Toggle } from '@/ui/Toggle';
import { FormField } from '@/ui/FormField';
import { SponsorDashboardHeader } from '@/components/sponsors/SponsorDashboardHeader';
import {
Building2,
Save,
Bell,
AlertCircle,
RefreshCw
} from 'lucide-react';
export interface SponsorSettingsViewData {
profile: {
companyName: string;
contactName: string;
contactEmail: string;
description: string;
industry: string;
};
notifications: {
emailNewSponsorships: boolean;
emailWeeklyReport: boolean;
emailPaymentAlerts: boolean;
};
privacy: {
publicProfile: boolean;
showStats: boolean;
showActiveSponsorships: boolean;
allowDirectContact: boolean;
};
}
interface SponsorSettingsTemplateProps {
viewData: SponsorSettingsViewData;
profile: {
companyName: string;
contactName: string;
contactEmail: string;
description: string;
industry: string;
};
setProfile: (profile: {
companyName: string;
contactName: string;
contactEmail: string;
description: string;
industry: string;
}) => void;
notifications: {
emailNewSponsorships: boolean;
emailWeeklyReport: boolean;
emailPaymentAlerts: boolean;
};
setNotifications: (notifications: {
emailNewSponsorships: boolean;
emailWeeklyReport: boolean;
emailPaymentAlerts: boolean;
}) => void;
onSaveProfile: () => void | Promise<void>;
onDeleteAccount: () => void | Promise<void>;
saving: boolean;
saved: boolean;
}
export function SponsorSettingsTemplate({
profile,
setProfile,
notifications,
setNotifications,
onSaveProfile,
onDeleteAccount,
saving,
}: SponsorSettingsTemplateProps) {
return (
<Container size="md" py={8}>
<Stack gap={8}>
<SponsorDashboardHeader
sponsorName={profile.companyName}
onRefresh={() => console.log('Refresh')}
/>
<Stack gap={6}>
{/* Company Profile */}
<Card>
<Stack gap={6}>
<Heading level={3} icon={<Icon icon={Building2} size={5} color="text-primary-blue" />}>
Company Profile
</Heading>
<Box display="grid" gridCols={{ base: 1, md: 2 }} gap={6}>
<FormField label="Company Name" required>
<Input
value={profile.companyName}
onChange={(e) => setProfile({ ...profile, companyName: e.target.value })}
/>
</FormField>
<FormField label="Industry">
<Input
value={profile.industry}
onChange={(e) => setProfile({ ...profile, industry: e.target.value })}
/>
</FormField>
<FormField label="Contact Name" required>
<Input
value={profile.contactName}
onChange={(e) => setProfile({ ...profile, contactName: e.target.value })}
/>
</FormField>
<FormField label="Contact Email" required>
<Input
value={profile.contactEmail}
onChange={(e) => setProfile({ ...profile, contactEmail: e.target.value })}
/>
</FormField>
</Box>
<FormField label="Company Description">
<Box
as="textarea"
rows={4}
value={profile.description}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setProfile({ ...profile, description: e.target.value })}
w="full"
p={3}
rounded="lg"
border
borderColor="border-charcoal-outline"
bg="bg-iron-gray/50"
color="text-white"
outline="none"
focusBorderColor="border-primary-blue"
/>
</FormField>
<Box display="flex" justifyContent="end">
<Button
variant="primary"
onClick={onSaveProfile}
disabled={saving}
icon={<Icon icon={saving ? RefreshCw : Save} size={4} animate={saving ? 'spin' : undefined} />}
>
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</Box>
</Stack>
</Card>
{/* Notifications */}
<Card>
<Stack gap={6}>
<Heading level={3} icon={<Icon icon={Bell} size={5} color="text-warning-amber" />}>
Notifications
</Heading>
<Stack gap={4}>
<Toggle
label="Sponsorship Approvals"
description="Receive confirmation when your sponsorship requests are approved"
checked={notifications.emailNewSponsorships}
onChange={(checked) => setNotifications({ ...notifications, emailNewSponsorships: checked })}
/>
<Toggle
label="Weekly Analytics Report"
description="Get a weekly summary of your sponsorship performance"
checked={notifications.emailWeeklyReport}
onChange={(checked) => setNotifications({ ...notifications, emailWeeklyReport: checked })}
/>
<Toggle
label="Payment & Invoice Notifications"
description="Receive invoices and payment confirmations"
checked={notifications.emailPaymentAlerts}
onChange={(checked) => setNotifications({ ...notifications, emailPaymentAlerts: checked })}
/>
</Stack>
</Stack>
</Card>
{/* Danger Zone */}
<Card border borderColor="border-racing-red/30">
<Stack gap={6}>
<Heading level={3} color="text-racing-red" icon={<Icon icon={AlertCircle} size={5} color="text-racing-red" />}>
Danger Zone
</Heading>
<Box display="flex" alignItems="center" justifyContent="between">
<Box>
<Text weight="medium" color="text-white" block>Delete Sponsor Account</Text>
<Text size="sm" color="text-gray-500">Permanently remove your account and all data.</Text>
</Box>
<Button variant="danger" onClick={onDeleteAccount}>
Delete Account
</Button>
</Box>
</Stack>
</Card>
</Stack>
</Stack>
</Container>
);
}

View File

@@ -1,95 +1,39 @@
'use client';
import React from 'react';
import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Surface } from '@/ui/Surface';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData';
import { SponsorshipRequestsPanel } from '@/components/profile/SponsorshipRequestsPanel';
export interface SponsorshipRequestsTemplateProps {
viewData: SponsorshipRequestsViewData;
onAccept: (requestId: string) => Promise<void>;
onReject: (requestId: string, reason?: string) => Promise<void>;
processingId?: string | null;
}
export function SponsorshipRequestsTemplate({
viewData,
onAccept,
onReject,
processingId,
}: SponsorshipRequestsTemplateProps) {
return (
<Container size="md" py={8}>
<Stack gap={8}>
<Box>
<Heading level={1}>Sponsorship Requests</Heading>
<Text size="sm" color="text-gray-400" block mt={2}>
Manage pending sponsorship requests for your profile.
</Text>
</Box>
<Stack gap={8}>
<Box as="header">
<Heading level={1}>Sponsorship Requests</Heading>
</Box>
{viewData.sections.map((section) => (
<Card key={`${section.entityType}-${section.entityId}`}>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={2}>{section.entityName}</Heading>
<Text size="xs" color="text-gray-400">
{section.requests.length} {section.requests.length === 1 ? 'request' : 'requests'}
</Text>
</Stack>
{section.requests.length === 0 ? (
<Text size="sm" color="text-gray-400">No pending requests.</Text>
) : (
<Stack gap={3}>
{section.requests.map((request) => (
<Surface
key={request.id}
variant="muted"
rounded="lg"
border
padding={4}
>
<Stack direction="row" align="center" justify="between" wrap gap={4}>
<Box flexGrow={1} minWidth="0">
<Text weight="medium" color="text-white" block>{request.sponsorName}</Text>
{request.message && (
<Text size="xs" color="text-gray-400" block mt={1}>{request.message}</Text>
)}
<Text size="xs" color="text-gray-500" block mt={2}>
{DateDisplay.formatShort(request.createdAtIso)}
</Text>
</Box>
<Stack direction="row" gap={2}>
<Button
variant="primary"
onClick={() => onAccept(request.id)}
size="sm"
>
Accept
</Button>
<Button
variant="secondary"
onClick={() => onReject(request.id)}
size="sm"
>
Reject
</Button>
</Stack>
</Stack>
</Surface>
))}
</Stack>
)}
</Stack>
</Card>
))}
</Stack>
</Container>
<Box as="main">
<SponsorshipRequestsPanel
sections={viewData.sections}
onAccept={onAccept}
onReject={onReject}
processingId={processingId}
/>
</Box>
</Stack>
);
}

View File

@@ -2,12 +2,11 @@
import React from 'react';
import { Breadcrumbs } from '@/ui/Breadcrumbs';
import { SlotTemplates } from '@/components/sponsors/SlotTemplates';
import { SponsorInsightsCard } from '@/components/sponsors/SponsorInsightsCard';
import { SlotTemplates } from '@/components/sponsors/SlotTemplates';
import { useSponsorMode } from '@/hooks/sponsor/useSponsorMode';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Container } from '@/ui/Container';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
@@ -15,12 +14,11 @@ import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { HorizontalStatItem } from '@/ui/HorizontalStatItem';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { TeamDetailsHeader } from '@/components/teams/TeamDetailsHeader';
import { TeamMembersTable } from '@/components/teams/TeamMembersTable';
import { TeamStandingsPanel } from '@/components/teams/TeamStandingsPanel';
import { TeamAdmin } from '@/components/teams/TeamAdmin';
import { TeamHero } from '@/components/teams/TeamHero';
import { TeamRoster } from '@/components/teams/TeamRoster';
import { TeamStandings } from '@/components/teams/TeamStandings';
import type { TeamDetailViewData } from '@/lib/view-data/TeamDetailViewData';
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
@@ -34,7 +32,6 @@ export interface TeamDetailTemplateProps {
onTabChange: (tab: Tab) => void;
onUpdate: () => void;
onRemoveMember: (driverId: string) => void;
onChangeRole: (driverId: string, newRole: 'owner' | 'admin' | 'member') => void;
onGoBack: () => void;
}
@@ -45,170 +42,178 @@ export function TeamDetailTemplate({
onTabChange,
onUpdate,
onRemoveMember,
onChangeRole,
onGoBack,
}: TeamDetailTemplateProps) {
const isSponsorMode = useSponsorMode();
const team = viewData.team;
// Show loading state
if (loading) {
return (
<Container size="lg" py={12}>
<Stack align="center">
<Text color="text-gray-400">Loading team...</Text>
<Box bg="base-black" minH="screen" display="flex" center>
<Stack align="center" gap={4}>
<Box w="12" h="12" border={4} borderColor="primary-accent" borderOpacity={0.2} borderTop borderTopColor="primary-accent" rounded="full" animate="spin" />
<Text color="text-gray-500" font="mono" size="xs" uppercase letterSpacing="widest">Synchronizing Telemetry...</Text>
</Stack>
</Container>
</Box>
);
}
// Show not found state
if (!team) {
return (
<Container size="md" py={12}>
<Card>
<Stack align="center" py={12} gap={6}>
<Box textAlign="center">
<Heading level={1}>Team Not Found</Heading>
<Text color="text-gray-400" block mt={2}>
The team you&apos;re looking for doesn&apos;t exist or has been disbanded.
</Text>
<Box bg="base-black" minH="screen">
<Container size="md" py={24}>
<Box border borderColor="outline-steel" bg="surface-charcoal" p={12} textAlign="center">
<Heading level={1}>404: Team Disconnected</Heading>
<Text color="text-gray-500" block mt={4} font="mono">
The requested team entity is no longer broadcasting.
</Text>
<Box mt={8}>
<Button variant="primary" onClick={onGoBack}>
Return to Base
</Button>
</Box>
<Button variant="primary" onClick={onGoBack}>
Go Back
</Button>
</Stack>
</Card>
</Container>
</Box>
</Container>
</Box>
);
}
return (
<Container size="lg" py={8}>
<Stack gap={6}>
{/* Breadcrumb */}
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Teams', href: '/teams' },
{ label: team.name }
]}
/>
{/* Sponsor Insights Card */}
{isSponsorMode && team && (
<SponsorInsightsCard
entityType="team"
entityId={team.id}
entityName={team.name}
tier="standard"
metrics={viewData.teamMetrics}
slots={SlotTemplates.team(true, true, 500, 250)}
trustScore={90}
monthlyActivity={85}
onNavigate={(href) => window.location.href = href}
<Box bg="base-black" minH="screen">
<Container size="lg" py={8}>
<Stack gap={8}>
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Teams', href: '/teams' },
{ label: team.name }
]}
/>
)}
<TeamHero
team={{
...team,
leagues: team.leagues.map(id => ({ id }))
}}
memberCount={viewData.memberships.length}
onUpdate={onUpdate}
/>
{/* Tabs */}
<Box borderBottom={true} borderColor="border-charcoal-outline">
<Stack direction="row" gap={6}>
{viewData.tabs.map((tab) => (
tab.visible && (
<Box
key={tab.id}
onClick={() => onTabChange(tab.id)}
pb={3}
cursor="pointer"
borderBottom={activeTab === tab.id ? '2px solid' : '2px solid'}
borderColor={activeTab === tab.id ? 'border-primary-blue' : 'border-transparent'}
color={activeTab === tab.id ? 'text-primary-blue' : 'text-gray-400'}
>
<Text weight="medium">{tab.label}</Text>
</Box>
)
))}
</Stack>
</Box>
<Box>
{activeTab === 'overview' && (
<Stack gap={6}>
<Grid cols={12} gap={6}>
<GridItem colSpan={12} lgSpan={8}>
<Card>
<Box mb={4}>
<Heading level={2}>About</Heading>
</Box>
<Text color="text-gray-300" leading="relaxed">{team.description}</Text>
</Card>
</GridItem>
<GridItem colSpan={12} lgSpan={4}>
<Card>
<Box mb={4}>
<Heading level={2}>Quick Stats</Heading>
</Box>
<Stack gap={3}>
<HorizontalStatItem label="Members" value={viewData.memberships.length.toString()} color="text-primary-blue" />
{team.category && (
<HorizontalStatItem label="Category" value={team.category} color="text-purple-400" />
)}
{team.leagues && team.leagues.length > 0 && (
<HorizontalStatItem label="Leagues" value={team.leagues.length.toString()} color="text-green-400" />
)}
{team.createdAt && (
<HorizontalStatItem
label="Founded"
value={DateDisplay.formatMonthYear(team.createdAt)}
color="text-gray-300"
/>
)}
</Stack>
</Card>
</GridItem>
</Grid>
<Card>
<Box mb={4}>
<Heading level={2}>Recent Activity</Heading>
</Box>
<Box py={8}>
<Text color="text-gray-400" block align="center">No recent activity to display</Text>
</Box>
</Card>
</Stack>
)}
{activeTab === 'roster' && (
<TeamRoster
teamId={team.id}
memberships={viewData.memberships}
isAdmin={viewData.isAdmin}
onRemoveMember={onRemoveMember}
onChangeRole={onChangeRole}
{isSponsorMode && (
<SponsorInsightsCard
entityType="team"
entityId={team.id}
entityName={team.name}
tier="standard"
metrics={viewData.teamMetrics}
slots={SlotTemplates.team(true, true, 500, 250)}
trustScore={90}
monthlyActivity={85}
onNavigate={(href) => window.location.href = href}
/>
)}
{activeTab === 'standings' && (
<TeamStandings teamId={team.id} leagues={team.leagues} />
)}
<TeamDetailsHeader
teamId={team.id}
name={team.name}
tag={team.tag}
description={team.description}
memberCount={viewData.memberships.length}
foundedDate={team.createdAt}
isAdmin={viewData.isAdmin}
onAdminClick={() => onTabChange('admin')}
/>
{activeTab === 'admin' && viewData.isAdmin && (
<TeamAdmin team={team} onUpdate={onUpdate} />
)}
</Box>
</Stack>
</Container>
{/* Tabs */}
<Box borderBottom borderColor="outline-steel">
<Stack direction="row" gap={8}>
{viewData.tabs.map((tab) => (
tab.visible && (
<Box
key={tab.id}
onClick={() => onTabChange(tab.id)}
pb={4}
cursor="pointer"
position="relative"
>
<Text
weight="bold"
size="xs"
uppercase
letterSpacing="widest"
color={activeTab === tab.id ? 'text-primary-accent' : 'text-gray-500'}
>
{tab.label}
</Text>
{activeTab === tab.id && (
<Box
position="absolute"
bottom="-1px"
left="0"
right="0"
h="2px"
bg="primary-accent"
/>
)}
</Box>
)
))}
</Stack>
</Box>
<Box>
{activeTab === 'overview' && (
<Stack gap={8}>
<Grid cols={12} gap={8}>
<GridItem colSpan={12} lgSpan={8}>
<Stack gap={8}>
<Box border borderColor="outline-steel" bg="surface-charcoal" bgOpacity={0.3} p={6}>
<Text size="xs" weight="bold" color="text-gray-400" uppercase>Mission Statement</Text>
<Text color="text-gray-300" leading="relaxed" size="sm" block mt={4}>
{team.description || 'No description provided.'}
</Text>
</Box>
<Box>
<Text size="xs" weight="bold" color="text-gray-400" uppercase mb={4} block>Recent Operations</Text>
<Box py={12} border borderStyle="dashed" borderColor="outline-steel" bg="surface-charcoal" bgOpacity={0.1} textAlign="center">
<Text color="text-gray-600" font="mono" size="xs">NO RECENT TELEMETRY DATA</Text>
</Box>
</Box>
</Stack>
</GridItem>
<GridItem colSpan={12} lgSpan={4}>
<Stack gap={6}>
<Box border borderColor="outline-steel" bg="surface-charcoal" bgOpacity={0.5} p={6}>
<Text size="xs" weight="bold" color="text-gray-400" uppercase mb={4} block>Performance Metrics</Text>
<Stack gap={4}>
<HorizontalStatItem label="Avg Rating" value="1450" color="text-primary-accent" />
<HorizontalStatItem label="Win Rate" value="12.5%" color="text-telemetry-aqua" />
<HorizontalStatItem label="Total Podiums" value="42" color="text-warning-amber" />
</Stack>
</Box>
</Stack>
</GridItem>
</Grid>
</Stack>
)}
{activeTab === 'roster' && (
<Box>
<Stack direction="row" align="center" justify="between" mb={6}>
<Text size="xs" weight="bold" color="text-gray-400" uppercase>Active Personnel</Text>
<Text size="xs" color="text-gray-500" font="mono">{viewData.memberships.length} UNITS ACTIVE</Text>
</Stack>
<TeamMembersTable
members={viewData.memberships}
isAdmin={viewData.isAdmin}
onRemoveMember={onRemoveMember}
/>
</Box>
)}
{activeTab === 'standings' && (
<TeamStandingsPanel standings={[]} /> // Mocked for now as in original
)}
{activeTab === 'admin' && viewData.isAdmin && (
<TeamAdmin team={team} onUpdate={onUpdate} />
)}
</Box>
</Stack>
</Container>
</Box>
);
}

View File

@@ -1,18 +1,16 @@
'use client';
import React from 'react';
import { Award, ArrowLeft } from 'lucide-react';
import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';
import { Award, ChevronLeft } from 'lucide-react';
import { Container } from '@/ui/Container';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Container } from '@/ui/Container';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { ModalIcon } from '@/ui/ModalIcon';
import { TeamPodium } from '@/components/teams/TeamPodium';
import { TeamFilter } from '@/ui/TeamFilter';
import { TeamRankingsTable } from '@/components/teams/TeamRankingsTable';
import { Table, TableBody, TableHead, TableHeader, TableRow, TableCell } from '@/ui/Table';
import { LeaderboardFiltersBar } from '@/components/leaderboards/LeaderboardFiltersBar';
import type { TeamLeaderboardViewData, SkillLevel, SortBy } from '@/lib/view-data/TeamLeaderboardViewData';
interface TeamLeaderboardTemplateProps {
@@ -27,64 +25,93 @@ interface TeamLeaderboardTemplateProps {
export function TeamLeaderboardTemplate({
viewData,
onSearchChange,
filterLevelChange,
onSortChange,
onTeamClick,
onBackToTeams,
}: TeamLeaderboardTemplateProps) {
const { searchQuery, filterLevel, sortBy, filteredAndSortedTeams } = viewData;
const { searchQuery, filteredAndSortedTeams } = viewData;
return (
<Container size="lg" py={8}>
<Stack gap={8}>
{/* Header */}
<Box>
<Box mb={6}>
<Button
variant="secondary"
onClick={onBackToTeams}
icon={<Icon icon={ArrowLeft} size={4} />}
>
Back to Teams
</Button>
</Box>
<Stack direction="row" align="center" gap={4}>
<ModalIcon
icon={Award}
color="text-yellow-400"
bgColor="bg-yellow-400/10"
borderColor="border-yellow-400/30"
/>
<Box>
<Heading level={1}>Team Leaderboard</Heading>
<Text color="text-gray-400" block mt={1}>Rankings of all teams by performance metrics</Text>
</Box>
<Box bg="base-black" minH="screen">
<Container size="lg" py={12}>
<Stack gap={8}>
{/* Header */}
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={4}>
<Button variant="secondary" size="sm" onClick={onBackToTeams} icon={<Icon icon={ChevronLeft} size={4} />}>
Back
</Button>
<Box>
<Heading level={1} weight="bold">Global Standings</Heading>
<Text color="text-gray-500" size="sm" font="mono" uppercase letterSpacing="widest">Team Performance Index</Text>
</Box>
</Stack>
<Icon icon={Award} size={8} color="warning-amber" />
</Stack>
</Box>
{/* Filters and Search */}
<TeamFilter
searchQuery={searchQuery}
onSearchChange={onSearchChange}
filterLevel={filterLevel}
onFilterLevelChange={filterLevelChange}
sortBy={sortBy}
onSortChange={onSortChange}
/>
<LeaderboardFiltersBar
searchQuery={searchQuery}
onSearchChange={onSearchChange}
placeholder="Search teams..."
/>
{/* Podium for Top 3 */}
{sortBy === 'rating' && filterLevel === 'all' && !searchQuery && filteredAndSortedTeams.length >= 3 && (
<TeamPodium teams={filteredAndSortedTeams} onClick={onTeamClick} />
)}
{/* Leaderboard Table */}
<TeamRankingsTable
teams={filteredAndSortedTeams}
sortBy={sortBy}
onTeamClick={onTeamClick}
/>
</Stack>
</Container>
<Box border borderColor="outline-steel" bg="surface-charcoal/30">
<Table>
<TableHead>
<TableRow>
<TableHeader w="20">Rank</TableHeader>
<TableHeader>Team</TableHeader>
<TableHeader textAlign="center">Personnel</TableHeader>
<TableHeader textAlign="center">Races</TableHeader>
<TableHeader textAlign="right">Rating</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{filteredAndSortedTeams.length > 0 ? (
filteredAndSortedTeams.map((team, index) => (
<TableRow
key={team.id}
onClick={() => onTeamClick(team.id)}
cursor="pointer"
hoverBg="surface-charcoal/50"
>
<TableCell>
<Text font="mono" weight="bold" color={index < 3 ? 'warning-amber' : 'text-gray-500'}>
#{index + 1}
</Text>
</TableCell>
<TableCell>
<Stack direction="row" align="center" gap={3}>
<Box w="8" h="8" bg="base-black" border borderColor="outline-steel" display="flex" center>
<Text size="xs" weight="bold" color="primary-accent">{team.name.substring(0, 2).toUpperCase()}</Text>
</Box>
<Text weight="bold" size="sm" color="text-white">{team.name}</Text>
</Stack>
</TableCell>
<TableCell textAlign="center">
<Text size="xs" color="text-gray-400" font="mono">{team.memberCount}</Text>
</TableCell>
<TableCell textAlign="center">
<Text size="xs" color="text-gray-400" font="mono">{team.totalRaces}</Text>
</TableCell>
<TableCell textAlign="right">
<Text font="mono" weight="bold" color="primary-accent">1450</Text>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} textAlign="center" py={12}>
<Text color="text-gray-600" font="mono" size="xs" uppercase letterSpacing="widest">
No teams found matching criteria
</Text>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Box>
</Stack>
</Container>
</Box>
);
}

View File

@@ -2,17 +2,15 @@
import React from 'react';
import { Users } from 'lucide-react';
import { TeamLeaderboardPreview } from '@/components/teams/TeamLeaderboardPreviewWrapper';
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Container } from '@/ui/Container';
import { Grid } from '@/ui/Grid';
import { TeamCard } from '@/ui/TeamCardWrapper';
import { EmptyState } from '@/components/shared/state/EmptyState';
import type { TeamSummaryData, TeamsViewData } from '@/lib/view-data/TeamsViewData';
import { TeamsDirectoryHeader } from '@/components/teams/TeamsDirectoryHeader';
import { TeamGrid } from '@/components/teams/TeamGrid';
import { TeamLeaderboardPreview } from '@/components/teams/TeamLeaderboardPreviewWrapper';
import type { TeamsViewData } from '@/lib/view-data/TeamsViewData';
interface TeamsTemplateProps {
viewData: TeamsViewData;
@@ -25,50 +23,39 @@ export function TeamsTemplate({ viewData, onTeamClick, onViewFullLeaderboard, on
const { teams } = viewData;
return (
<Box as="main">
<Container size="lg" py={8}>
<Stack gap={8}>
{/* Header */}
<Stack direction="row" align="center" justify="between" wrap gap={4}>
<Box>
<Heading level={1}>Teams</Heading>
<Text color="text-gray-400">Browse and manage your racing teams</Text>
</Box>
<Box>
<Button variant="primary" onClick={onCreateTeam}>Create Team</Button>
</Box>
</Stack>
<Box as="main" bg="base-black" minH="screen">
<Container size="lg" py={12}>
<Stack gap={10}>
<TeamsDirectoryHeader onCreateTeam={onCreateTeam} />
{/* Teams Grid */}
{teams.length > 0 ? (
<Grid cols={3} gap={6}>
{teams.map((team: TeamSummaryData) => (
<TeamCard
key={team.teamId}
id={team.teamId}
name={team.teamName}
logo={team.logoUrl}
memberCount={team.memberCount}
leagues={[team.leagueName]}
onClick={() => onTeamClick?.(team.teamId)}
/>
))}
</Grid>
) : (
<EmptyState
icon={Users}
title="No teams yet"
description="Get started by creating your first racing team"
action={{
label: 'Create Team',
onClick: onCreateTeam,
variant: 'primary'
}}
/>
)}
<Box>
<Stack direction="row" align="center" gap={2} mb={6}>
<Box w="2" h="2" bg="primary-accent" />
<Text size="xs" weight="bold" color="text-gray-400" uppercase>Active Rosters</Text>
</Stack>
{teams.length > 0 ? (
<TeamGrid teams={teams} onTeamClick={onTeamClick} />
) : (
<EmptyState
icon={Users}
title="No teams yet"
description="Get started by creating your first racing team"
action={{
label: 'Create Team',
onClick: onCreateTeam,
variant: 'primary'
}}
/>
)}
</Box>
{/* Team Leaderboard Preview */}
<Box mt={12}>
<Box pt={10} borderTop borderColor="outline-steel" borderOpacity={0.3}>
<Stack direction="row" align="center" gap={2} mb={6}>
<Box w="2" h="2" bg="telemetry-aqua" />
<Text size="xs" weight="bold" color="text-gray-400" uppercase>Global Standings</Text>
</Stack>
<TeamLeaderboardPreview
topTeams={[]}
onTeamClick={(id) => onTeamClick?.(id)}

View File

@@ -0,0 +1,21 @@
'use client';
import { ActionsViewData } from '@/lib/view-data/ActionsViewData';
import { ActionsHeader } from '@/components/actions/ActionsHeader';
import { ActionList } from '@/components/actions/ActionList';
import { ActionFiltersBar } from '@/components/actions/ActionFiltersBar';
import { Box } from '@/ui/Box';
export function ActionsTemplate({ actions }: ActionsViewData) {
return (
<Box display="flex" flexDirection="col" fullHeight bg="bg-[#0C0D0F]" color="text-white">
<ActionsHeader title="Telemetry Actions" />
<Box display="flex" flexDirection="col" flexGrow={1} overflow="hidden">
<ActionFiltersBar />
<Box flexGrow={1} overflow="auto" p={4}>
<ActionList actions={actions} />
</Box>
</Box>
</Box>
);
}

View File

@@ -1,25 +1,18 @@
'use client';
import React from 'react';
import {
Mail,
ArrowLeft,
AlertCircle,
Flag,
Shield,
CheckCircle2,
} from 'lucide-react';
import { Card } from '@/ui/Card';
import { Mail, ArrowLeft, AlertCircle, Shield, CheckCircle2 } from 'lucide-react';
import { Button } from '@/ui/Button';
import { Input } from '@/ui/Input';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { LoadingSpinner } from '@/ui/LoadingSpinner';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
import { AuthCard } from '@/components/auth/AuthCard';
import { AuthForm } from '@/components/auth/AuthForm';
import { AuthFooterLinks } from '@/components/auth/AuthFooterLinks';
import { routes } from '@/lib/routing/RouteConfig';
import { ForgotPasswordViewData } from '@/lib/builders/view-data/types/ForgotPasswordViewData';
@@ -37,145 +30,97 @@ interface ForgotPasswordTemplateProps {
}
export function ForgotPasswordTemplate({ viewData, formActions, mutationState }: ForgotPasswordTemplateProps) {
const isSubmitting = mutationState.isPending;
return (
<Box as="main" minHeight="100vh" display="flex" alignItems="center" justifyContent="center" position="relative">
{/* Background Pattern */}
<Box position="absolute" inset={0} bg="linear-gradient(to bottom right, rgba(59, 130, 246, 0.05), transparent, rgba(147, 51, 234, 0.05))" />
<Box position="relative" w="full" maxWidth="28rem" px={4}>
{/* Header */}
<Box textAlign="center" mb={8}>
<Surface variant="muted" rounded="2xl" border padding={4} w="4rem" h="4rem" display="flex" alignItems="center" justifyContent="center" mx="auto" mb={4}>
<Icon icon={Flag} size={8} color="#3b82f6" />
</Surface>
<Heading level={1}>Reset Password</Heading>
<Text color="text-gray-400" block mt={2}>
Enter your email and we will send you a reset link
</Text>
</Box>
<AuthCard
title="Reset Password"
description={viewData.showSuccess ? undefined : "Enter your email and we'll send you a reset link"}
>
{!viewData.showSuccess ? (
<AuthForm onSubmit={formActions.handleSubmit}>
<Input
label="Email Address"
id="email"
name="email"
type="email"
value={viewData.formState.fields.email.value}
onChange={formActions.handleChange}
errorMessage={viewData.formState.fields.email.error}
placeholder="you@example.com"
disabled={isSubmitting}
autoComplete="email"
icon={<Mail size={16} />}
/>
<Card position="relative" overflow="hidden">
{/* Background accent */}
<Box position="absolute" top={0} right={0} w="8rem" h="8rem" bg="linear-gradient(to bottom left, rgba(59, 130, 246, 0.1), transparent)" />
{!viewData.showSuccess ? (
<Box as="form" onSubmit={formActions.handleSubmit}>
<Stack gap={5} position="relative">
{/* Email */}
<Box>
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
Email Address
</Text>
<Box position="relative">
<Box position="absolute" left="3" top="50%" zIndex={10}>
<Icon icon={Mail} size={4} color="#6b7280" />
</Box>
<Input
id="email"
type="email"
value={viewData.formState.fields.email.value}
onChange={formActions.handleChange}
variant={viewData.formState.fields.email.error ? 'error' : 'default'}
placeholder="you@example.com"
disabled={mutationState.isPending}
autoComplete="email"
/>
</Box>
{viewData.formState.fields.email.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.email.error}
</Text>
)}
</Box>
{/* Error Message */}
{mutationState.error && (
<Surface variant="muted" rounded="lg" border padding={3} bg="bg-red-500/10" borderColor="border-red-500/30">
<Stack direction="row" align="start" gap={3}>
<Icon icon={AlertCircle} size={5} color="#ef4444" />
<Text size="sm" color="text-error-red">{mutationState.error}</Text>
</Stack>
</Surface>
)}
{/* Submit Button */}
<Button
type="submit"
variant="primary"
disabled={mutationState.isPending}
fullWidth
icon={mutationState.isPending ? <LoadingSpinner size={4} color="white" /> : <Icon icon={Shield} size={4} />}
>
{mutationState.isPending ? 'Sending...' : 'Send Reset Link'}
</Button>
{/* Back to Login */}
<Box textAlign="center">
<Link href={routes.auth.login}>
<Stack direction="row" align="center" justify="center" gap={1}>
<Icon icon={ArrowLeft} size={4} color="#3b82f6" />
<Text size="sm" color="text-primary-blue">Back to Login</Text>
</Stack>
</Link>
</Box>
{mutationState.error && (
<Box p={4} bg="critical-red/10" border borderColor="critical-red/30" rounded="md">
<Stack direction="row" align="start" gap={3}>
<Icon icon={AlertCircle} size={4.5} color="var(--color-critical)" />
<Text size="sm" color="text-critical-red">{mutationState.error}</Text>
</Stack>
</Box>
) : (
<Stack gap={4} position="relative">
<Surface variant="muted" rounded="lg" border padding={4} bg="bg-green-500/10" borderColor="border-green-500/30">
<Stack direction="row" align="start" gap={3}>
<Icon icon={CheckCircle2} size={6} color="#10b981" />
<Box>
<Text size="sm" color="text-performance-green" weight="medium" block>{viewData.successMessage}</Text>
{viewData.magicLink && (
<Box mt={2}>
<Text size="xs" color="text-gray-400" block mb={1}>Development Mode - Magic Link:</Text>
<Surface variant="muted" rounded="md" border padding={2} bg="bg-neutral-800">
<Text size="xs" color="text-primary-blue">{viewData.magicLink}</Text>
</Surface>
<Text size="xs" color="text-gray-500" block mt={1}>
In production, this would be sent via email
</Text>
</Box>
)}
</Box>
</Stack>
</Surface>
<Button
type="button"
variant="secondary"
onClick={() => window.location.href = '/auth/login'}
fullWidth
>
Return to Login
</Button>
</Stack>
)}
</Card>
{/* Trust Indicators */}
<Stack direction="row" align="center" justify="center" gap={6} mt={6}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Shield} size={4} color="#737373" />
<Text size="sm" color="text-gray-500">Secure reset process</Text>
</Stack>
<Stack direction="row" align="center" gap={2}>
<Icon icon={CheckCircle2} size={4} color="#737373" />
<Text size="sm" color="text-gray-500">15 minute expiration</Text>
</Stack>
</Stack>
<Button
type="submit"
variant="primary"
disabled={isSubmitting}
fullWidth
icon={isSubmitting ? <LoadingSpinner size={4} /> : <Shield size={16} />}
>
{isSubmitting ? 'Sending...' : 'Send Reset Link'}
</Button>
{/* Footer */}
<Box mt={6} textAlign="center">
<Text size="xs" color="text-gray-500">
Need help?{' '}
<Link href="/support">
<Text color="text-gray-400">Contact support</Text>
<Box textAlign="center">
<Link href={routes.auth.login}>
<Stack direction="row" align="center" justify="center" gap={2} group>
<Icon icon={ArrowLeft} size={3.5} color="var(--color-primary)" groupHoverScale />
<Text size="sm" weight="bold" color="text-primary-accent">Back to Login</Text>
</Stack>
</Link>
</Text>
</Box>
</Box>
</Box>
</Box>
</AuthForm>
) : (
<Stack gap={6}>
<Box p={4} bg="success-green/10" border borderColor="success-green/30" rounded="md">
<Stack direction="row" align="start" gap={3}>
<Icon icon={CheckCircle2} size={5} color="var(--color-success)" />
<Box>
<Text size="sm" color="text-success-green" weight="bold" block>Check your email</Text>
<Text size="xs" color="text-gray-400" block mt={1}>{viewData.successMessage}</Text>
</Box>
</Stack>
</Box>
{viewData.magicLink && (
<Box p={3} bg="surface-charcoal" border borderColor="outline-steel" rounded="md">
<Text size="xs" color="text-gray-500" block mb={2} weight="bold">DEVELOPMENT MAGIC LINK</Text>
<Link href={viewData.magicLink}>
<Text size="xs" color="text-primary-accent" block>
{viewData.magicLink}
</Text>
</Link>
</Box>
)}
<Button
type="button"
variant="secondary"
onClick={() => window.location.href = '/auth/login'}
fullWidth
>
Return to Login
</Button>
</Stack>
)}
<AuthFooterLinks>
<Text size="xs" color="text-gray-600">
Need help?{' '}
<Link href="/support">Contact support</Link>
</Text>
</AuthFooterLinks>
</AuthCard>
);
}

View File

@@ -1,30 +1,20 @@
'use client';
import React from 'react';
import {
Mail,
Lock,
Eye,
EyeOff,
LogIn,
AlertCircle,
Flag,
Shield,
} from 'lucide-react';
import { Card } from '@/ui/Card';
import { LogIn, Mail, AlertCircle } from 'lucide-react';
import { Button } from '@/ui/Button';
import { Input } from '@/ui/Input';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { Box } from '@/ui/Box';
import { LoadingSpinner } from '@/ui/LoadingSpinner';
import { PasswordField } from '@/ui/PasswordField';
import { AuthCard } from '@/components/auth/AuthCard';
import { AuthForm } from '@/components/auth/AuthForm';
import { AuthFooterLinks } from '@/components/auth/AuthFooterLinks';
import { EnhancedFormError } from '@/components/errors/EnhancedFormError';
import { UserRolesPreview } from '@/components/auth/UserRolesPreview';
import { AuthWorkflowMockup } from '@/components/auth/AuthWorkflowMockup';
import { routes } from '@/lib/routing/RouteConfig';
import { LoginViewData } from '@/lib/builders/view-data/types/LoginViewData';
import { FormState } from '@/lib/builders/view-data/types/FormState';
@@ -45,264 +35,129 @@ interface LoginTemplateProps {
}
export function LoginTemplate({ viewData, formActions, mutationState }: LoginTemplateProps) {
const isSubmitting = viewData.formState.isSubmitting || mutationState.isPending;
return (
<Box as="main" minHeight="100vh" display="flex" position="relative">
{/* Background Pattern */}
<Box position="absolute" inset="0" bg="linear-gradient(to bottom right, rgba(59, 130, 246, 0.05), transparent, rgba(147, 51, 234, 0.05))" />
{/* Left Side - Info Panel (Hidden on mobile) */}
<Box display={{ base: 'none', lg: 'flex' }} w={{ lg: '1/2' }} position="relative" alignItems="center" justifyContent="center" p={12}>
<Box maxWidth="32rem">
{/* Logo */}
<Stack direction="row" align="center" gap={3} mb={8}>
<Surface variant="muted" rounded="xl" border padding={2} bg="bg-blue-500/10" borderColor="border-blue-500/30">
<Icon icon={Flag} size={6} color="#3b82f6" />
</Surface>
<Text size="2xl" weight="bold" color="text-white">GridPilot</Text>
</Stack>
<AuthCard
title="Welcome Back"
description="Sign in to access your racing dashboard"
>
<AuthForm onSubmit={formActions.handleSubmit}>
<Stack gap={4}>
<Input
label="Email Address"
id="email"
name="email"
type="email"
value={viewData.formState.fields.email.value as string}
onChange={formActions.handleChange}
errorMessage={viewData.formState.fields.email.error}
placeholder="you@example.com"
disabled={isSubmitting}
autoComplete="email"
icon={<Mail size={16} />}
/>
<Box mb={4}>
<Heading level={2}>
Your Sim Racing Infrastructure
</Heading>
</Box>
<Text size="lg" color="text-gray-400" block mb={8}>
Manage leagues, track performance, join teams, and compete with drivers worldwide. One account, multiple roles.
</Text>
{/* Role Cards */}
<UserRolesPreview variant="full" />
{/* Workflow Mockup */}
<Box mt={8}>
<AuthWorkflowMockup />
</Box>
{/* Trust Indicators */}
<Stack direction="row" align="center" gap={6} mt={8}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Shield} size={4} color="#737373" />
<Text size="sm" color="text-gray-500">Secure login</Text>
</Stack>
<Text size="sm" color="text-gray-500">iRacing verified</Text>
</Stack>
</Box>
</Box>
{/* Right Side - Login Form */}
<Box flex={1} display="flex" alignItems="center" justifyContent="center" p={{ base: 4, lg: 12 }} position="relative">
<Box w="full" maxWidth="28rem">
{/* Mobile Logo/Header */}
<Box display={{ base: 'block', lg: 'none' }} textAlign="center" mb={8}>
<Surface variant="muted" rounded="2xl" border padding={4} w="4rem" h="4rem" display="flex" alignItems="center" justifyContent="center" mx="auto" mb={4}>
<Icon icon={Flag} size={8} color="#3b82f6" />
</Surface>
<Heading level={1}>Welcome Back</Heading>
<Text color="text-gray-400" block mt={2}>
Sign in to continue to GridPilot
</Text>
</Box>
{/* Desktop Header */}
<Box display={{ base: 'none', lg: 'block' }} textAlign="center" mb={8}>
<Heading level={2}>Welcome Back</Heading>
<Text color="text-gray-400" block mt={2}>
Sign in to access your racing dashboard
</Text>
</Box>
<Card position="relative" overflow="hidden">
{/* Background accent */}
<Box position="absolute" top="0" right="0" w="8rem" h="8rem" bg="linear-gradient(to bottom left, rgba(59, 130, 246, 0.1), transparent)" />
<Box as="form" onSubmit={formActions.handleSubmit}>
<Stack gap={5} position="relative">
{/* Email */}
<Box>
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
Email Address
</Text>
<Box position="relative">
<Box position="absolute" left="3" top="50%" zIndex={10}>
<Icon icon={Mail} size={4} color="#6b7280" />
</Box>
<Input
id="email"
name="email"
type="email"
value={viewData.formState.fields.email.value as string}
onChange={formActions.handleChange}
variant={viewData.formState.fields.email.error ? 'error' : 'default'}
placeholder="you@example.com"
disabled={viewData.formState.isSubmitting || mutationState.isPending}
autoComplete="email"
/>
</Box>
{viewData.formState.fields.email.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.email.error}
</Text>
)}
</Box>
{/* Password */}
<Box>
<Stack direction="row" align="center" justify="between" mb={2}>
<Text size="sm" weight="medium" color="text-gray-300">
Password
</Text>
<Link href={routes.auth.forgotPassword}>
<Text size="xs" color="text-primary-blue">Forgot password?</Text>
</Link>
</Stack>
<Box position="relative">
<Box position="absolute" left="3" top="50%" zIndex={10}>
<Icon icon={Lock} size={4} color="#6b7280" />
</Box>
<Input
id="password"
name="password"
type={viewData.showPassword ? 'text' : 'password'}
value={viewData.formState.fields.password.value as string}
onChange={formActions.handleChange}
variant={viewData.formState.fields.password.error ? 'error' : 'default'}
placeholder="••••••••"
disabled={viewData.formState.isSubmitting || mutationState.isPending}
autoComplete="current-password"
/>
<Box
as="button"
type="button"
onClick={() => formActions.setShowPassword(!viewData.showPassword)}
position="absolute"
right="3"
top="50%"
zIndex={10}
bg="transparent"
borderStyle="none"
cursor="pointer"
>
<Icon icon={viewData.showPassword ? EyeOff : Eye} size={4} color="#6b7280" />
</Box>
</Box>
{viewData.formState.fields.password.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.password.error}
</Text>
)}
</Box>
{/* Remember Me */}
<Stack direction="row" align="center" gap={2}>
<Box
as="input"
id="rememberMe"
name="rememberMe"
type="checkbox"
checked={viewData.formState.fields.rememberMe.value as boolean}
onChange={formActions.handleChange}
disabled={viewData.formState.isSubmitting || mutationState.isPending}
/>
<Text size="sm" color="text-gray-300">Keep me signed in</Text>
</Stack>
{/* Insufficient Permissions Message */}
{viewData.hasInsufficientPermissions && (
<Surface variant="muted" rounded="lg" border padding={4} bg="bg-amber-500/10" borderColor="border-amber-500/30">
<Stack direction="row" align="start" gap={3}>
<Icon icon={AlertCircle} size={5} color="#f59e0b" />
<Box>
<Text weight="bold" color="text-warning-amber" block>Insufficient Permissions</Text>
<Text size="sm" color="text-gray-300" block mt={1}>
You don&apos;t have permission to access that page. Please log in with an account that has the required role.
</Text>
</Box>
</Stack>
</Surface>
)}
{/* Enhanced Error Display */}
{viewData.submitError && (
<EnhancedFormError
error={new Error(viewData.submitError)}
onDismiss={() => {
formActions.setFormState((prev: FormState) => ({ ...prev, submitError: undefined }));
}}
showDeveloperDetails={viewData.showErrorDetails}
/>
)}
{/* Submit Button */}
<Button
type="submit"
variant="primary"
disabled={viewData.formState.isSubmitting || mutationState.isPending}
fullWidth
icon={mutationState.isPending || viewData.formState.isSubmitting ? <LoadingSpinner size={4} color="white" /> : <Icon icon={LogIn} size={4} />}
>
{mutationState.isPending || viewData.formState.isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
</Stack>
</Box>
{/* Divider */}
<Box position="relative" my={6}>
<Box position="absolute" inset="0" display="flex" alignItems="center">
<Box w="full" borderTop borderColor="border-neutral-800" />
</Box>
<Box position="relative" display="flex" justifyContent="center">
<Box px={4} bg="bg-neutral-900">
<Text size="xs" color="text-gray-500">or continue with</Text>
</Box>
</Box>
</Box>
{/* Sign Up Link */}
<Box textAlign="center" mt={6}>
<Text size="sm" color="text-gray-400">
Don&apos;t have an account?{' '}
<Link
href={viewData.returnTo && viewData.returnTo !== '/dashboard' ? `/auth/signup?returnTo=${encodeURIComponent(viewData.returnTo)}` : '/auth/signup'}
>
<Text color="text-primary-blue" weight="medium">Create one</Text>
</Link>
</Text>
</Box>
</Card>
{/* Name Immutability Notice */}
<Box mt={6}>
<Surface variant="muted" rounded="lg" border padding={4} bg="bg-neutral-800/30" borderColor="border-neutral-800">
<Stack direction="row" align="start" gap={3}>
<Icon icon={AlertCircle} size={5} color="#737373" />
<Text size="xs" color="text-gray-400">
<Text weight="bold">Note:</Text> Your display name cannot be changed after signup. Please ensure it&apos;s correct when creating your account.
<Stack gap={1.5}>
<PasswordField
label="Password"
id="password"
name="password"
value={viewData.formState.fields.password.value as string}
onChange={formActions.handleChange}
errorMessage={viewData.formState.fields.password.error}
placeholder="••••••••"
disabled={isSubmitting}
autoComplete="current-password"
showPassword={viewData.showPassword}
onTogglePassword={() => formActions.setShowPassword(!viewData.showPassword)}
/>
<Box textAlign="right">
<Link href={routes.auth.forgotPassword}>
<Text size="xs" color="text-primary-accent">
Forgot password?
</Text>
</Stack>
</Surface>
</Box>
</Link>
</Box>
</Stack>
{/* Footer */}
<Box mt={6} textAlign="center">
<Text size="xs" color="text-gray-500">
By signing in, you agree to our{' '}
<Link href="/terms">
<Text color="text-gray-400">Terms of Service</Text>
</Link>
{' '}and{' '}
<Link href="/privacy">
<Text color="text-gray-400">Privacy Policy</Text>
</Link>
<Stack direction="row" align="center" gap={2}>
<Box
as="input"
id="rememberMe"
name="rememberMe"
type="checkbox"
rounded="sm"
borderColor="outline-steel"
bg="surface-charcoal"
color="text-primary-accent"
ring="focus:ring-primary-accent/50"
w="1rem"
h="1rem"
checked={viewData.formState.fields.rememberMe.value as boolean}
onChange={formActions.handleChange}
disabled={isSubmitting}
/>
<Text as="label" htmlFor="rememberMe" size="sm" color="text-med" cursor="pointer">
Keep me signed in
</Text>
</Box>
</Stack>
</Stack>
{/* Mobile Role Info */}
<Box mt={8} display={{ base: 'block', lg: 'none' }}>
<UserRolesPreview variant="compact" />
{viewData.hasInsufficientPermissions && (
<Box p={4} bg="warning-amber/10" border borderColor="warning-amber/30" rounded="md">
<Stack direction="row" align="start" gap={3}>
<Icon icon={AlertCircle} size={5} color="var(--color-warning)" />
<Box>
<Text weight="bold" color="text-warning-amber" block size="sm">Insufficient Permissions</Text>
<Text size="xs" color="text-gray-400" block mt={1}>
Please log in with an account that has the required role.
</Text>
</Box>
</Stack>
</Box>
)}
{viewData.submitError && (
<EnhancedFormError
error={new Error(viewData.submitError)}
onDismiss={() => {
formActions.setFormState((prev: FormState) => ({ ...prev, submitError: undefined }));
}}
showDeveloperDetails={viewData.showErrorDetails}
/>
)}
<Button
type="submit"
variant="primary"
disabled={isSubmitting}
fullWidth
icon={isSubmitting ? <LoadingSpinner size={4} /> : <LogIn size={16} />}
>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
</AuthForm>
<AuthFooterLinks>
<Text size="sm" color="text-gray-400">
Don&apos;t have an account?{' '}
<Link
href={viewData.returnTo && viewData.returnTo !== '/dashboard' ? `/auth/signup?returnTo=${encodeURIComponent(viewData.returnTo)}` : '/auth/signup'}
>
<Text color="text-primary-accent" weight="bold">Create one</Text>
</Link>
</Text>
<Box mt={2}>
<Text size="xs" color="text-gray-600">
By signing in, you agree to our{' '}
<Link href="/terms">Terms</Link>
{' '}and{' '}
<Link href="/privacy">Privacy</Link>
</Text>
</Box>
</Box>
</Box>
</AuthFooterLinks>
</AuthCard>
);
}

View File

@@ -1,27 +1,18 @@
'use client';
import React from 'react';
import {
Lock,
Eye,
EyeOff,
AlertCircle,
Flag,
Shield,
CheckCircle2,
ArrowLeft,
} from 'lucide-react';
import { Card } from '@/ui/Card';
import { Shield, CheckCircle2, ArrowLeft, AlertCircle } from 'lucide-react';
import { Button } from '@/ui/Button';
import { Input } from '@/ui/Input';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { LoadingSpinner } from '@/ui/LoadingSpinner';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
import { AuthCard } from '@/components/auth/AuthCard';
import { AuthForm } from '@/components/auth/AuthForm';
import { PasswordField } from '@/ui/PasswordField';
import { AuthFooterLinks } from '@/components/auth/AuthFooterLinks';
import { routes } from '@/lib/routing/RouteConfig';
import { ResetPasswordViewData } from '@/lib/builders/view-data/types/ResetPasswordViewData';
@@ -50,194 +41,102 @@ export function ResetPasswordTemplate({
uiState,
mutationState,
}: ResetPasswordTemplateProps) {
const isSubmitting = mutationState.isPending;
return (
<Box as="main" minHeight="100vh" display="flex" alignItems="center" justifyContent="center" position="relative">
{/* Background Pattern */}
<Box position="absolute" inset="0" bg="linear-gradient(to bottom right, rgba(59, 130, 246, 0.05), transparent, rgba(147, 51, 234, 0.05))" />
<Box position="relative" w="full" maxWidth="28rem" px={4}>
{/* Header */}
<Box textAlign="center" mb={8}>
<Surface variant="muted" rounded="2xl" border padding={4} w="4rem" h="4rem" display="flex" alignItems="center" justifyContent="center" mx="auto" mb={4}>
<Icon icon={Flag} size={8} color="#3b82f6" />
</Surface>
<Heading level={1}>Reset Password</Heading>
<Text color="text-gray-400" block mt={2}>
Create a new secure password for your account
</Text>
</Box>
<AuthCard
title="Reset Password"
description={viewData.showSuccess ? undefined : "Create a new secure password for your account"}
>
{!viewData.showSuccess ? (
<AuthForm onSubmit={formActions.handleSubmit}>
<Stack gap={4}>
<PasswordField
label="New Password"
id="newPassword"
name="newPassword"
value={viewData.formState.fields.newPassword.value}
onChange={formActions.handleChange}
errorMessage={viewData.formState.fields.newPassword.error}
placeholder="••••••••"
disabled={isSubmitting}
autoComplete="new-password"
showPassword={uiState.showPassword}
onTogglePassword={() => formActions.setShowPassword(!uiState.showPassword)}
/>
<Card position="relative" overflow="hidden">
{/* Background accent */}
<Box position="absolute" top="0" right="0" w="8rem" h="8rem" bg="linear-gradient(to bottom left, rgba(59, 130, 246, 0.1), transparent)" />
<PasswordField
label="Confirm Password"
id="confirmPassword"
name="confirmPassword"
value={viewData.formState.fields.confirmPassword.value}
onChange={formActions.handleChange}
errorMessage={viewData.formState.fields.confirmPassword.error}
placeholder="••••••••"
disabled={isSubmitting}
autoComplete="new-password"
showPassword={uiState.showConfirmPassword}
onTogglePassword={() => formActions.setShowConfirmPassword(!uiState.showConfirmPassword)}
/>
</Stack>
{!viewData.showSuccess ? (
<Box as="form" onSubmit={formActions.handleSubmit}>
<Stack gap={5} position="relative">
{/* New Password */}
<Box>
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
New Password
</Text>
<Box position="relative">
<Box position="absolute" left="3" top="50%" zIndex={10}>
<Icon icon={Lock} size={4} color="#6b7280" />
</Box>
<Input
id="newPassword"
name="newPassword"
type={uiState.showPassword ? 'text' : 'password'}
value={viewData.formState.fields.newPassword.value}
onChange={formActions.handleChange}
variant={viewData.formState.fields.newPassword.error ? 'error' : 'default'}
placeholder="••••••••"
disabled={mutationState.isPending}
autoComplete="new-password"
/>
<Box
as="button"
type="button"
onClick={() => formActions.setShowPassword(!uiState.showPassword)}
position="absolute"
right="3"
top="50%"
zIndex={10}
bg="transparent"
borderStyle="none"
cursor="pointer"
>
<Icon icon={uiState.showPassword ? EyeOff : Eye} size={4} color="#6b7280" />
</Box>
</Box>
{viewData.formState.fields.newPassword.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.newPassword.error}
</Text>
)}
</Box>
{/* Confirm Password */}
<Box>
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
Confirm Password
</Text>
<Box position="relative">
<Box position="absolute" left="3" top="50%" zIndex={10}>
<Icon icon={Lock} size={4} color="#6b7280" />
</Box>
<Input
id="confirmPassword"
name="confirmPassword"
type={uiState.showConfirmPassword ? 'text' : 'password'}
value={viewData.formState.fields.confirmPassword.value}
onChange={formActions.handleChange}
variant={viewData.formState.fields.confirmPassword.error ? 'error' : 'default'}
placeholder="••••••••"
disabled={mutationState.isPending}
autoComplete="new-password"
/>
<Box
as="button"
type="button"
onClick={() => formActions.setShowConfirmPassword(!uiState.showConfirmPassword)}
position="absolute"
right="3"
top="50%"
zIndex={10}
bg="transparent"
borderStyle="none"
cursor="pointer"
>
<Icon icon={uiState.showConfirmPassword ? EyeOff : Eye} size={4} color="#6b7280" />
</Box>
</Box>
{viewData.formState.fields.confirmPassword.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.confirmPassword.error}
</Text>
)}
</Box>
{/* Error Message */}
{mutationState.error && (
<Surface variant="muted" rounded="lg" border padding={3} bg="bg-red-500/10" borderColor="border-red-500/30">
<Stack direction="row" align="start" gap={3}>
<Icon icon={AlertCircle} size={5} color="#ef4444" />
<Text size="sm" color="text-error-red">{mutationState.error}</Text>
</Stack>
</Surface>
)}
{/* Submit Button */}
<Button
type="submit"
variant="primary"
disabled={mutationState.isPending}
fullWidth
icon={mutationState.isPending ? <LoadingSpinner size={4} color="white" /> : <Icon icon={Shield} size={4} />}
>
{mutationState.isPending ? 'Resetting...' : 'Reset Password'}
</Button>
{/* Back to Login */}
<Box textAlign="center">
<Link href={routes.auth.login}>
<Stack direction="row" align="center" justify="center" gap={1}>
<Icon icon={ArrowLeft} size={4} color="#3b82f6" />
<Text size="sm" color="text-primary-blue">Back to Login</Text>
</Stack>
</Link>
</Box>
{mutationState.error && (
<Box p={4} bg="critical-red/10" border borderColor="critical-red/30" rounded="md">
<Stack direction="row" align="start" gap={3}>
<Icon icon={AlertCircle} size={4.5} color="var(--color-critical)" />
<Text size="sm" color="text-critical-red">{mutationState.error}</Text>
</Stack>
</Box>
) : (
<Stack gap={4} position="relative">
<Surface variant="muted" rounded="lg" border padding={4} bg="bg-green-500/10" borderColor="border-green-500/30">
<Stack direction="row" align="start" gap={3}>
<Icon icon={CheckCircle2} size={6} color="#10b981" />
<Box>
<Text size="sm" color="text-performance-green" weight="medium" block>{viewData.successMessage}</Text>
<Text size="xs" color="text-gray-400" block mt={1}>
Your password has been successfully reset
</Text>
</Box>
</Stack>
</Surface>
<Button
type="button"
variant="secondary"
onClick={() => window.location.href = '/auth/login'}
fullWidth
>
Return to Login
</Button>
</Stack>
)}
</Card>
{/* Trust Indicators */}
<Stack direction="row" align="center" justify="center" gap={6} mt={6}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Shield} size={4} color="#737373" />
<Text size="sm" color="text-gray-500">Secure password reset</Text>
</Stack>
<Stack direction="row" align="center" gap={2}>
<Icon icon={CheckCircle2} size={4} color="#737373" />
<Text size="sm" color="text-gray-500">Encrypted transmission</Text>
</Stack>
</Stack>
<Button
type="submit"
variant="primary"
disabled={isSubmitting}
fullWidth
icon={isSubmitting ? <LoadingSpinner size={4} /> : <Shield size={16} />}
>
{isSubmitting ? 'Resetting...' : 'Reset Password'}
</Button>
{/* Footer */}
<Box mt={6} textAlign="center">
<Text size="xs" color="text-gray-500">
Need help?{' '}
<Link href="/support">
<Text color="text-gray-400">Contact support</Text>
<Box textAlign="center">
<Link href={routes.auth.login}>
<Stack direction="row" align="center" justify="center" gap={2} group>
<Icon icon={ArrowLeft} size={3.5} color="var(--color-primary)" groupHoverScale />
<Text size="sm" weight="bold" color="text-primary-accent">Back to Login</Text>
</Stack>
</Link>
</Text>
</Box>
</Box>
</Box>
</Box>
</AuthForm>
) : (
<Stack gap={6}>
<Box p={4} bg="success-green/10" border borderColor="success-green/30" rounded="md">
<Stack direction="row" align="start" gap={3}>
<Icon icon={CheckCircle2} size={5} color="var(--color-success)" />
<Box>
<Text size="sm" color="text-success-green" weight="bold" block>Password Reset</Text>
<Text size="xs" color="text-gray-400" block mt={1}>{viewData.successMessage}</Text>
</Box>
</Stack>
</Box>
<Button
type="button"
variant="secondary"
onClick={() => window.location.href = '/auth/login'}
fullWidth
>
Return to Login
</Button>
</Stack>
)}
<AuthFooterLinks>
<Text size="xs" color="text-gray-600">
Need help?{' '}
<Link href="/support">Contact support</Link>
</Text>
</AuthFooterLinks>
</AuthCard>
);
}

View File

@@ -1,34 +1,19 @@
'use client';
import React from 'react';
import {
Mail,
Lock,
Eye,
EyeOff,
UserPlus,
AlertCircle,
Flag,
User,
Check,
X,
Car,
Users,
Trophy,
Shield,
Sparkles,
} from 'lucide-react';
import { Card } from '@/ui/Card';
import { UserPlus, Mail, User, Check, X, AlertCircle } from 'lucide-react';
import { Button } from '@/ui/Button';
import { Input } from '@/ui/Input';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { LoadingSpinner } from '@/ui/LoadingSpinner';
import { PasswordField } from '@/ui/PasswordField';
import { AuthCard } from '@/components/auth/AuthCard';
import { AuthForm } from '@/components/auth/AuthForm';
import { AuthFooterLinks } from '@/components/auth/AuthFooterLinks';
import { SignupViewData } from '@/lib/builders/view-data/types/SignupViewData';
import { checkPasswordStrength } from '@/lib/utils/validation';
@@ -50,432 +35,180 @@ interface SignupTemplateProps {
};
}
const USER_ROLES = [
{
icon: Car,
title: 'Driver',
description: 'Race, track stats, join teams',
color: '#3b82f6',
bg: 'bg-blue-500/10',
},
{
icon: Trophy,
title: 'League Admin',
description: 'Organize leagues and events',
color: '#10b981',
bg: 'bg-green-500/10',
},
{
icon: Users,
title: 'Team Manager',
description: 'Manage team and drivers',
color: '#a855f7',
bg: 'bg-purple-500/10',
},
];
const FEATURES = [
'Track your racing statistics and progress',
'Join or create competitive leagues',
'Build or join racing teams',
'Connect your iRacing account',
'Compete in organized events',
'Access detailed performance analytics',
];
export function SignupTemplate({ viewData, formActions, uiState, mutationState }: SignupTemplateProps) {
const passwordStrength = checkPasswordStrength(viewData.formState.fields.password.value);
const isSubmitting = mutationState.isPending;
const passwordValue = viewData.formState.fields.password.value || '';
const passwordStrength = checkPasswordStrength(passwordValue);
const passwordRequirements = [
{ met: viewData.formState.fields.password.value.length >= 8, label: 'At least 8 characters' },
{ met: /[a-z]/.test(viewData.formState.fields.password.value) && /[A-Z]/.test(viewData.formState.fields.password.value), label: 'Upper and lowercase letters' },
{ met: /\d/.test(viewData.formState.fields.password.value), label: 'At least one number' },
{ met: /[^a-zA-Z\d]/.test(viewData.formState.fields.password.value), label: 'At least one special character' },
{ met: passwordValue.length >= 8, label: '8+ characters' },
{ met: /[a-z]/.test(passwordValue) && /[A-Z]/.test(passwordValue), label: 'Case mix' },
{ met: /\d/.test(passwordValue), label: 'Number' },
{ met: /[^a-zA-Z\d]/.test(passwordValue), label: 'Special' },
];
return (
<Box as="main" minHeight="100vh" display="flex" position="relative">
{/* Background Pattern */}
<Box position="absolute" inset="0" bg="linear-gradient(to bottom right, rgba(59, 130, 246, 0.05), transparent, rgba(147, 51, 234, 0.05))" />
{/* Left Side - Info Panel (Hidden on mobile) */}
<Box display={{ base: 'none', lg: 'flex' }} w={{ lg: '1/2' }} position="relative" alignItems="center" justifyContent="center" p={12}>
<Box maxWidth="32rem">
{/* Logo */}
<Stack direction="row" align="center" gap={3} mb={8}>
<Surface variant="muted" rounded="xl" border padding={2} bg="bg-blue-500/10" borderColor="border-blue-500/30">
<Icon icon={Flag} size={6} color="#3b82f6" />
</Surface>
<Text size="2xl" weight="bold" color="text-white">GridPilot</Text>
<AuthCard
title="Create Account"
description="Join the GridPilot racing community"
>
<AuthForm onSubmit={formActions.handleSubmit}>
<Stack gap={6}>
<Stack gap={4}>
<Text size="xs" weight="bold" color="text-low" uppercase letterSpacing="wide" block>Personal Information</Text>
<Box display="grid" gridCols={{ base: 1, md: 2 }} gap={4}>
<Input
label="First Name"
id="firstName"
name="firstName"
value={viewData.formState.fields.firstName.value}
onChange={formActions.handleChange}
errorMessage={viewData.formState.fields.firstName.error}
placeholder="John"
disabled={isSubmitting}
autoComplete="given-name"
icon={<User size={16} />}
/>
<Input
label="Last Name"
id="lastName"
name="lastName"
value={viewData.formState.fields.lastName.value}
onChange={formActions.handleChange}
errorMessage={viewData.formState.fields.lastName.error}
placeholder="Smith"
disabled={isSubmitting}
autoComplete="family-name"
icon={<User size={16} />}
/>
</Box>
<Box p={3} bg="warning-amber/5" border borderColor="warning-amber/20" rounded="md">
<Stack direction="row" align="start" gap={2}>
<Icon icon={AlertCircle} size={3.5} color="var(--color-warning)" mt={0.5} />
<Text size="xs" color="text-med">
<Text weight="bold" color="text-warning-amber">Note:</Text> Your name cannot be changed after signup.
</Text>
</Stack>
</Box>
<Input
label="Email Address"
id="email"
name="email"
type="email"
value={viewData.formState.fields.email.value}
onChange={formActions.handleChange}
errorMessage={viewData.formState.fields.email.error}
placeholder="you@example.com"
disabled={isSubmitting}
autoComplete="email"
icon={<Mail size={16} />}
/>
</Stack>
<Box mb={4}>
<Heading level={2}>Start Your Racing Journey</Heading>
</Box>
<Text size="lg" color="text-gray-400" block mb={8}>
Join thousands of sim racers. One account gives you access to all roles - race as a driver, organize leagues, or manage teams.
</Text>
<Stack gap={4}>
<Text size="xs" weight="bold" color="text-low" uppercase letterSpacing="wide" block>Security</Text>
<PasswordField
label="Password"
id="password"
name="password"
value={viewData.formState.fields.password.value}
onChange={formActions.handleChange}
errorMessage={viewData.formState.fields.password.error}
placeholder="••••••••"
disabled={isSubmitting}
autoComplete="new-password"
showPassword={uiState.showPassword}
onTogglePassword={() => formActions.setShowPassword(!uiState.showPassword)}
/>
{/* Role Cards */}
<Stack gap={3} mb={8}>
{USER_ROLES.map((role) => (
<Surface
key={role.title}
variant="muted"
rounded="xl"
border
padding={4}
bg="bg-neutral-800/30"
borderColor="border-neutral-800"
>
<Stack direction="row" align="center" gap={4}>
<Surface variant="muted" rounded="lg" padding={2} bg={role.bg}>
<Icon icon={role.icon} size={5} color={role.color} />
</Surface>
<Box>
<Text weight="medium" color="text-white" block>{role.title}</Text>
<Text size="sm" color="text-gray-500" block mt={1}>{role.description}</Text>
{passwordValue && (
<Stack gap={3}>
<Stack direction="row" align="center" gap={2}>
<Box flexGrow={1} h="1px" bg="outline-steel" rounded="full" overflow="hidden">
<Box
h="full"
transition
w={`${(passwordStrength.score / 5) * 100}%`}
bg={
passwordStrength.score <= 2 ? 'critical-red' :
passwordStrength.score <= 4 ? 'warning-amber' : 'success-green'
}
/>
</Box>
<Text size="xs" weight="bold" color="text-low" uppercase>
{passwordStrength.label}
</Text>
</Stack>
</Surface>
))}
</Stack>
{/* Features List */}
<Box mb={8}>
<Surface variant="muted" rounded="xl" border padding={5} bg="bg-neutral-800/20" borderColor="border-neutral-800">
<Stack direction="row" align="center" gap={2} mb={4}>
<Icon icon={Sparkles} size={4} color="#3b82f6" />
<Text size="sm" weight="medium" color="text-white">What you&apos;ll get</Text>
</Stack>
<Stack gap={2}>
{FEATURES.map((feature, index) => (
<Stack key={index} direction="row" align="center" gap={2}>
<Icon icon={Check} size={3.5} color="#10b981" />
<Text size="sm" color="text-gray-400">{feature}</Text>
</Stack>
))}
</Stack>
</Surface>
</Box>
{/* Trust Indicators */}
<Stack direction="row" align="center" gap={6}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Shield} size={4} color="#737373" />
<Text size="sm" color="text-gray-500">Secure signup</Text>
</Stack>
<Text size="sm" color="text-gray-500">iRacing integration</Text>
</Stack>
</Box>
</Box>
{/* Right Side - Signup Form */}
<Box flex={1} display="flex" alignItems="center" justifyContent="center" p={{ base: 4, lg: 12 }} position="relative" overflow="auto">
<Box w="full" maxWidth="28rem">
{/* Mobile Logo/Header */}
<Box display={{ base: 'block', lg: 'none' }} textAlign="center" mb={8}>
<Surface variant="muted" rounded="2xl" border padding={4} w="4rem" h="4rem" display="flex" alignItems="center" justifyContent="center" mx="auto" mb={4}>
<Icon icon={Flag} size={8} color="#3b82f6" />
</Surface>
<Heading level={1}>Join GridPilot</Heading>
<Text color="text-gray-400" block mt={2}>
Create your account and start racing
</Text>
</Box>
{/* Desktop Header */}
<Box display={{ base: 'none', lg: 'block' }} textAlign="center" mb={8}>
<Heading level={2}>Create Account</Heading>
<Text color="text-gray-400" block mt={2}>
Get started with your free account
</Text>
</Box>
<Card position="relative" overflow="hidden">
{/* Background accent */}
<Box position="absolute" top="0" right="0" w="8rem" h="8rem" bg="linear-gradient(to bottom left, rgba(59, 130, 246, 0.1), transparent)" />
<Box as="form" onSubmit={formActions.handleSubmit}>
<Stack gap={4} position="relative">
{/* First Name */}
<Box>
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
First Name
</Text>
<Box position="relative">
<Box position="absolute" left="3" top="50%" zIndex={10}>
<Icon icon={User} size={4} color="#6b7280" />
</Box>
<Input
id="firstName"
name="firstName"
type="text"
value={viewData.formState.fields.firstName.value}
onChange={formActions.handleChange}
variant={viewData.formState.fields.firstName.error ? 'error' : 'default'}
placeholder="John"
disabled={mutationState.isPending}
autoComplete="given-name"
/>
</Box>
{viewData.formState.fields.firstName.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.firstName.error}
</Text>
)}
</Box>
{/* Last Name */}
<Box>
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
Last Name
</Text>
<Box position="relative">
<Box position="absolute" left="3" top="50%" zIndex={10}>
<Icon icon={User} size={4} color="#6b7280" />
</Box>
<Input
id="lastName"
name="lastName"
type="text"
value={viewData.formState.fields.lastName.value}
onChange={formActions.handleChange}
variant={viewData.formState.fields.lastName.error ? 'error' : 'default'}
placeholder="Smith"
disabled={mutationState.isPending}
autoComplete="family-name"
/>
</Box>
{viewData.formState.fields.lastName.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.lastName.error}
</Text>
)}
<Text size="xs" color="text-gray-500" block mt={1}>Your name will be used as-is and cannot be changed later</Text>
</Box>
{/* Name Immutability Warning */}
<Surface variant="muted" rounded="lg" border padding={3} bg="bg-amber-500/10" borderColor="border-amber-500/30">
<Stack direction="row" align="start" gap={3}>
<Icon icon={AlertCircle} size={5} color="#f59e0b" />
<Text size="sm" color="text-warning-amber">
<Text weight="bold">Important:</Text> Your name cannot be changed after signup. Please ensure it&apos;s correct.
</Text>
</Stack>
</Surface>
{/* Email */}
<Box>
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
Email Address
</Text>
<Box position="relative">
<Box position="absolute" left="3" top="50%" zIndex={10}>
<Icon icon={Mail} size={4} color="#6b7280" />
</Box>
<Input
id="email"
name="email"
type="email"
value={viewData.formState.fields.email.value}
onChange={formActions.handleChange}
variant={viewData.formState.fields.email.error ? 'error' : 'default'}
placeholder="you@example.com"
disabled={mutationState.isPending}
autoComplete="email"
/>
</Box>
{viewData.formState.fields.email.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.email.error}
</Text>
)}
</Box>
{/* Password */}
<Box>
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
Password
</Text>
<Box position="relative">
<Box position="absolute" left="3" top="50%" zIndex={10}>
<Icon icon={Lock} size={4} color="#6b7280" />
</Box>
<Input
id="password"
name="password"
type={uiState.showPassword ? 'text' : 'password'}
value={viewData.formState.fields.password.value}
onChange={formActions.handleChange}
variant={viewData.formState.fields.password.error ? 'error' : 'default'}
placeholder="••••••••"
disabled={mutationState.isPending}
autoComplete="new-password"
/>
<Box
as="button"
type="button"
onClick={() => formActions.setShowPassword(!uiState.showPassword)}
position="absolute"
right="3"
top="50%"
zIndex={10}
bg="transparent"
borderStyle="none"
cursor="pointer"
>
<Icon icon={uiState.showPassword ? EyeOff : Eye} size={4} color="#6b7280" />
</Box>
</Box>
{viewData.formState.fields.password.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.password.error}
</Text>
)}
{/* Password Strength */}
{viewData.formState.fields.password.value && (
<Box mt={3}>
<Stack direction="row" align="center" gap={2} mb={2}>
<Box flex={1} h="1.5" rounded="full" bg="bg-neutral-800" overflow="hidden">
<Box h="full" w={`${(passwordStrength.score / 5) * 100}%`} bg={passwordStrength.color === 'bg-red-500' ? 'bg-red-500' : passwordStrength.color === 'bg-yellow-500' ? 'bg-amber-500' : passwordStrength.color === 'bg-blue-500' ? 'bg-blue-500' : 'bg-green-500'} />
</Box>
<Text size="xs" weight="medium" color={passwordStrength.color === 'bg-red-500' ? 'text-red-400' : passwordStrength.color === 'bg-yellow-500' ? 'text-amber-400' : passwordStrength.color === 'bg-blue-500' ? 'text-blue-400' : 'text-green-400'}>
{passwordStrength.label}
</Text>
</Stack>
<Box display="grid" gridCols={2} gap={1}>
{passwordRequirements.map((req, index) => (
<Stack key={index} direction="row" align="center" gap={1.5}>
<Icon icon={req.met ? Check : X} size={3} color={req.met ? '#10b981' : '#525252'} />
<Text size="xs" color={req.met ? 'text-gray-300' : 'text-gray-500'}>
{req.label}
</Text>
</Stack>
))}
</Box>
</Box>
)}
</Box>
{/* Confirm Password */}
<Box>
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
Confirm Password
</Text>
<Box position="relative">
<Box position="absolute" left="3" top="50%" zIndex={10}>
<Icon icon={Lock} size={4} color="#6b7280" />
</Box>
<Input
id="confirmPassword"
name="confirmPassword"
type={uiState.showConfirmPassword ? 'text' : 'password'}
value={viewData.formState.fields.confirmPassword.value}
onChange={formActions.handleChange}
variant={viewData.formState.fields.confirmPassword.error ? 'error' : 'default'}
placeholder="••••••••"
disabled={mutationState.isPending}
autoComplete="new-password"
/>
<Box
as="button"
type="button"
onClick={() => formActions.setShowConfirmPassword(!uiState.showConfirmPassword)}
position="absolute"
right="3"
top="50%"
zIndex={10}
bg="transparent"
borderStyle="none"
cursor="pointer"
>
<Icon icon={uiState.showConfirmPassword ? EyeOff : Eye} size={4} color="#6b7280" />
</Box>
</Box>
{viewData.formState.fields.confirmPassword.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.confirmPassword.error}
</Text>
)}
{viewData.formState.fields.confirmPassword.value && viewData.formState.fields.password.value === viewData.formState.fields.confirmPassword.value && (
<Stack direction="row" align="center" gap={1} mt={1}>
<Icon icon={Check} size={3} color="#10b981" />
<Text size="xs" color="text-performance-green">Passwords match</Text>
<Box display="grid" gridCols={2} gap={2}>
{passwordRequirements.map((req, index) => (
<Stack key={index} direction="row" align="center" gap={1.5}>
<Icon icon={req.met ? Check : X} size={3} color={req.met ? 'var(--color-success)' : 'var(--color-text-low)'} />
<Text size="xs" color={req.met ? 'text-med' : 'text-low'}>
{req.label}
</Text>
</Stack>
)}
))}
</Box>
{/* Submit Button */}
<Button
type="submit"
variant="primary"
disabled={mutationState.isPending}
fullWidth
icon={mutationState.isPending ? <LoadingSpinner size={4} color="white" /> : <Icon icon={UserPlus} size={4} />}
>
{mutationState.isPending ? 'Creating account...' : 'Create Account'}
</Button>
</Stack>
</Box>
)}
{/* Divider */}
<Box position="relative" my={6}>
<Box position="absolute" inset="0" display="flex" alignItems="center">
<Box w="full" borderTop borderColor="border-neutral-800" />
</Box>
<Box position="relative" display="flex" justifyContent="center">
<Box px={4} bg="bg-neutral-900">
<Text size="xs" color="text-gray-500">or continue with</Text>
</Box>
</Box>
</Box>
<PasswordField
label="Confirm Password"
id="confirmPassword"
name="confirmPassword"
value={viewData.formState.fields.confirmPassword.value}
onChange={formActions.handleChange}
errorMessage={viewData.formState.fields.confirmPassword.error}
placeholder="••••••••"
disabled={isSubmitting}
autoComplete="new-password"
showPassword={uiState.showConfirmPassword}
onTogglePassword={() => formActions.setShowConfirmPassword(!uiState.showConfirmPassword)}
/>
</Stack>
</Stack>
{/* Login Link */}
<Box textAlign="center" mt={6}>
<Text size="sm" color="text-gray-400">
Already have an account?{' '}
<Link
href={viewData.returnTo && viewData.returnTo !== '/onboarding' ? `/auth/login?returnTo=${encodeURIComponent(viewData.returnTo)}` : '/auth/login'}
>
<Text color="text-primary-blue" weight="medium">Sign in</Text>
</Link>
</Text>
</Box>
</Card>
{/* Footer */}
<Box mt={6} textAlign="center">
<Text size="xs" color="text-gray-500">
By creating an account, you agree to our{' '}
<Link href="/terms">
<Text color="text-gray-400">Terms of Service</Text>
</Link>
{' '}and{' '}
<Link href="/privacy">
<Text color="text-gray-400">Privacy Policy</Text>
</Link>
</Text>
</Box>
{/* Mobile Role Info */}
<Box mt={8} display={{ base: 'block', lg: 'none' }}>
<Text size="xs" color="text-gray-500" block mb={4} textAlign="center">One account for all roles</Text>
<Stack direction="row" align="center" justify="center" gap={6}>
{USER_ROLES.map((role) => (
<Stack key={role.title} align="center" gap={1}>
<Surface variant="muted" rounded="lg" padding={2} bg={role.bg}>
<Icon icon={role.icon} size={4} color={role.color} />
</Surface>
<Text size="xs" color="text-gray-500">{role.title}</Text>
</Stack>
))}
{mutationState.error && (
<Box p={4} bg="critical-red/10" border borderColor="critical-red/30" rounded="md">
<Stack direction="row" align="start" gap={3}>
<Icon icon={AlertCircle} size={4.5} color="var(--color-critical)" />
<Text size="sm" color="text-critical-red">{mutationState.error}</Text>
</Stack>
</Box>
)}
<Button
type="submit"
variant="primary"
disabled={isSubmitting}
fullWidth
icon={isSubmitting ? <LoadingSpinner size={4} /> : <UserPlus size={16} />}
>
{isSubmitting ? 'Creating account...' : 'Create Account'}
</Button>
</AuthForm>
<AuthFooterLinks>
<Text size="sm" color="text-gray-400">
Already have an account?{' '}
<Link
href={viewData.returnTo && viewData.returnTo !== '/onboarding' ? `/auth/login?returnTo=${encodeURIComponent(viewData.returnTo)}` : '/auth/login'}
>
<Text color="text-primary-accent" weight="bold">Sign in</Text>
</Link>
</Text>
<Box mt={2}>
<Text size="xs" color="text-gray-600">
By creating an account, you agree to our{' '}
<Link href="/terms">Terms</Link>
{' '}and{' '}
<Link href="/privacy">Privacy</Link>
</Text>
</Box>
</Box>
</Box>
</AuthFooterLinks>
</AuthCard>
);
}

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { AppFooter } from '@/ui/AppFooter';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import Image from 'next/image';
import Link from 'next/link';
export interface GlobalFooterViewData {}
export function GlobalFooterTemplate(_props: GlobalFooterViewData) {
return (
<AppFooter>
<Box maxWidth="7xl" mx="auto" display="grid" responsiveGridCols={{ base: 1, md: 4 }} gap={12}>
<Box colSpan={{ base: 1, md: 2 }}>
<Box mb={6} opacity={0.8}>
<Image
src="/images/logos/wordmark-rectangle-dark.svg"
alt="GridPilot"
width={140}
height={26}
/>
</Box>
<Box maxWidth="sm" mb={6}>
<Text color="text-gray-500">
The professional infrastructure for serious sim racing.
Precision telemetry, automated results, and elite league management.
</Text>
</Box>
<Box display="flex" alignItems="center" gap={4}>
<Text size="xs" color="text-gray-600" font="mono" letterSpacing="widest">
© 2026 GRIDPILOT
</Text>
</Box>
</Box>
<Box>
<Box mb={4}>
<Text weight="bold" color="text-gray-300" letterSpacing="wider">PLATFORM</Text>
</Box>
<Stack as="ul" direction="col" gap={2}>
<Box as="li">
<Box as={Link} href="/leagues" color="text-gray-500" hoverTextColor="primary-accent" transition>
Leagues
</Box>
</Box>
<Box as="li">
<Box as={Link} href="/teams" color="text-gray-500" hoverTextColor="primary-accent" transition>
Teams
</Box>
</Box>
<Box as="li">
<Box as={Link} href="/leaderboards" color="text-gray-500" hoverTextColor="primary-accent" transition>
Leaderboards
</Box>
</Box>
</Stack>
</Box>
<Box>
<Box mb={4}>
<Text weight="bold" color="text-gray-300" letterSpacing="wider">SUPPORT</Text>
</Box>
<Stack as="ul" direction="col" gap={2}>
<Box as="li">
<Box as={Link} href="/docs" color="text-gray-500" hoverTextColor="primary-accent" transition>
Documentation
</Box>
</Box>
<Box as="li">
<Box as={Link} href="/status" color="text-gray-500" hoverTextColor="primary-accent" transition>
System Status
</Box>
</Box>
<Box as="li">
<Box as={Link} href="/contact" color="text-gray-500" hoverTextColor="primary-accent" transition>
Contact
</Box>
</Box>
</Stack>
</Box>
</Box>
</AppFooter>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import React from 'react';
import { DashboardRail } from '@/ui/DashboardRail';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Trophy, Users, Calendar, Layout, Settings, Home } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
export interface GlobalSidebarViewData {}
export function GlobalSidebarTemplate(_props: GlobalSidebarViewData) {
const pathname = usePathname();
const navItems = [
{ label: 'Dashboard', href: '/', icon: Home },
{ label: 'Leagues', href: '/leagues', icon: Trophy },
{ label: 'Teams', href: '/teams', icon: Users },
{ label: 'Races', href: '/races', icon: Calendar },
{ label: 'Leaderboards', href: '/leaderboards', icon: Layout },
{ label: 'Settings', href: '/settings', icon: Settings },
];
return (
<DashboardRail>
<Box py={6}>
<Box px={6} mb={8}>
<Text size="xs" color="text-gray-500" weight="bold" font="mono" letterSpacing="0.2em">
NAVIGATION
</Text>
</Box>
<Stack as="nav" flexGrow={1} px={3} direction="col" gap={1}>
{navItems.map((item) => {
const isActive = pathname === item.href;
const Icon = item.icon;
return (
<Box
key={item.href}
as={Link}
href={item.href}
display="flex"
alignItems="center"
gap={3}
px={3}
py={2}
rounded="md"
transition
bg={isActive ? 'primary-accent/10' : 'transparent'}
color={isActive ? 'primary-accent' : 'text-gray-400'}
hoverBg={isActive ? 'primary-accent/10' : 'white/5'}
hoverTextColor={isActive ? 'primary-accent' : 'white'}
group
>
<Icon size={20} color={isActive ? '#198CFF' : '#6B7280'} />
<Text weight="medium">{item.label}</Text>
{isActive && (
<Box ml="auto" w="4px" h="16px" bg="primary-accent" rounded="full" shadow="[0_0_8px_rgba(25,140,255,0.5)]" />
)}
</Box>
);
})}
</Stack>
<Box px={6} mt="auto" pt={6} borderTop borderColor="[#23272B]">
<Stack direction="row" align="center" gap={2} mb={4}>
<Box w="8px" h="8px" rounded="full" bg="success-green" />
<Text size="xs" color="text-gray-400" font="mono">
SYSTEM ONLINE
</Text>
</Stack>
</Box>
</Box>
</DashboardRail>
);
}

View File

@@ -0,0 +1,53 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
export interface HeaderContentViewData {}
export function HeaderContentTemplate(_props: HeaderContentViewData) {
return (
<>
<Stack direction="row" align="center" gap={6}>
<Box as={Link} href="/" display="inline-flex" alignItems="center" group>
<Box position="relative">
<Box h={{ base: '24px', md: '28px' }} w="auto" transition opacity={1} groupHoverOpacity={0.8}>
<Image
src="/images/logos/wordmark-rectangle-dark.svg"
alt="GridPilot"
width={160}
height={30}
priority
/>
</Box>
<Box
position="absolute"
bottom="-4px"
left="0"
w="0"
h="2px"
bg="primary-accent"
transition
groupHoverWidth="full"
/>
</Box>
</Box>
<Box display={{ base: 'none', sm: 'flex' }} alignItems="center" gap={2} borderLeft borderColor="[#23272B]" pl={6}>
<Box w="6px" h="6px" rounded="full" bg="primary-accent" animate="pulse" />
<Text size="xs" color="text-gray-500" weight="bold" font="mono" letterSpacing="0.2em">
MOTORSPORT INFRASTRUCTURE
</Text>
</Box>
</Stack>
<Box display="flex" alignItems="center" gap={4}>
<Stack direction="row" display={{ base: 'none', md: 'flex' }} align="center" gap={1} px={3} py={1} border borderColor="[#23272B]" bg="[#141619]/20">
<Text size="xs" color="text-gray-600" weight="bold" font="mono">STATUS:</Text>
<Text size="xs" color="text-success-green" weight="bold" font="mono">OPERATIONAL</Text>
</Stack>
</Box>
</>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import React from 'react';
import { AppShell } from '@/ui/AppShell';
import { ControlBar } from '@/ui/ControlBar';
import { TopNav } from '@/ui/TopNav';
import { ContentViewport } from '@/ui/ContentViewport';
import { GlobalSidebarTemplate } from './GlobalSidebarTemplate';
import { GlobalFooterTemplate } from './GlobalFooterTemplate';
import { HeaderContentTemplate } from './HeaderContentTemplate';
import { Box } from '@/ui/Box';
export interface RootAppShellViewData {
children: React.ReactNode;
}
/**
* RootAppShellTemplate orchestrates the top-level semantic shells of the application.
* It follows the "Telemetry Workspace" structure:
* - ControlBar = header/control bar
* - DashboardRail = sidebar rail
* - ContentViewport = content area
*/
export function RootAppShellTemplate({ children }: RootAppShellViewData) {
return (
<AppShell>
<ControlBar>
<TopNav>
<HeaderContentTemplate />
</TopNav>
</ControlBar>
<Box display="flex" flexGrow={1} overflow="hidden">
<GlobalSidebarTemplate />
<Box display="flex" flexGrow={1} flexDirection="col" overflow="hidden">
<ContentViewport>
{children}
</ContentViewport>
<GlobalFooterTemplate />
</Box>
</Box>
</AppShell>
);
}

View File

@@ -0,0 +1,267 @@
'use client';
import { OnboardingShell } from '@/components/onboarding/OnboardingShell';
import { OnboardingStepper } from '@/components/onboarding/OnboardingStepper';
import { OnboardingHelpPanel } from '@/components/onboarding/OnboardingHelpPanel';
import { OnboardingStepPanel } from '@/components/onboarding/OnboardingStepPanel';
import { OnboardingPrimaryActions } from '@/components/onboarding/OnboardingPrimaryActions';
import { PersonalInfo, PersonalInfoStep } from '@/components/onboarding/PersonalInfoStep';
import { AvatarInfo, AvatarStep } from '@/components/onboarding/AvatarStep';
import { FormEvent } from 'react';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { OnboardingError } from '@/ui/OnboardingError';
type OnboardingStep = 1 | 2;
interface FormErrors {
[key: string]: string | undefined;
firstName?: string;
lastName?: string;
displayName?: string;
country?: string;
facePhoto?: string;
avatar?: string;
submit?: string;
}
export interface OnboardingViewData {
onCompleted: () => void;
onCompleteOnboarding: (data: {
firstName: string;
lastName: string;
displayName: string;
country: string;
timezone?: string;
}) => Promise<{ success: boolean; error?: string }>;
onGenerateAvatars: (params: {
facePhotoData: string;
suitColor: string;
}) => Promise<{ success: boolean; data?: { success: boolean; avatarUrls?: string[]; errorMessage?: string }; error?: string }>;
isProcessing: boolean;
step: OnboardingStep;
setStep: (step: OnboardingStep) => void;
errors: FormErrors;
setErrors: (errors: FormErrors) => void;
personalInfo: PersonalInfo;
setPersonalInfo: (info: PersonalInfo) => void;
avatarInfo: AvatarInfo;
setAvatarInfo: (info: AvatarInfo) => void;
}
interface OnboardingTemplateProps {
viewData: OnboardingViewData;
}
export function OnboardingTemplate({ viewData }: OnboardingTemplateProps) {
const {
onCompleted,
onCompleteOnboarding,
onGenerateAvatars,
isProcessing,
step,
setStep,
errors,
setErrors,
personalInfo,
setPersonalInfo,
avatarInfo,
setAvatarInfo
} = viewData;
const steps = ['Personal Info', 'Racing Avatar'];
// Validation
const validateStep = (currentStep: OnboardingStep): boolean => {
const newErrors: FormErrors = {};
if (currentStep === 1) {
if (!personalInfo.firstName.trim()) {
newErrors.firstName = 'First name is required';
}
if (!personalInfo.lastName.trim()) {
newErrors.lastName = 'Last name is required';
}
if (!personalInfo.displayName.trim()) {
newErrors.displayName = 'Display name is required';
} else if (personalInfo.displayName.length < 3) {
newErrors.displayName = 'Display name must be at least 3 characters';
}
if (!personalInfo.country) {
newErrors.country = 'Please select your country';
}
}
if (currentStep === 2) {
if (!avatarInfo.facePhoto) {
newErrors.facePhoto = 'Please upload a photo of your face';
}
if (avatarInfo.generatedAvatars.length > 0 && avatarInfo.selectedAvatarIndex === null) {
newErrors.avatar = 'Please select one of the generated avatars';
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleNext = () => {
const isValid = validateStep(step);
if (isValid && step < 2) {
setStep((step + 1) as OnboardingStep);
}
};
const handleBack = () => {
if (step > 1) {
setStep((step - 1) as OnboardingStep);
}
};
const generateAvatars = async () => {
if (!avatarInfo.facePhoto) {
setErrors({ ...errors, facePhoto: 'Please upload a photo first' });
return;
}
setAvatarInfo({ ...avatarInfo, isGenerating: true, generatedAvatars: [], selectedAvatarIndex: null });
const newErrors = { ...errors };
delete newErrors.avatar;
setErrors(newErrors);
try {
const result = await onGenerateAvatars({
facePhotoData: avatarInfo.facePhoto,
suitColor: avatarInfo.suitColor,
});
if (result.success && result.data?.success && result.data.avatarUrls) {
setAvatarInfo({
...avatarInfo,
generatedAvatars: result.data.avatarUrls,
isGenerating: false,
});
} else {
setErrors({ ...errors, avatar: result.data?.errorMessage || result.error || 'Failed to generate avatars' });
setAvatarInfo({ ...avatarInfo, isGenerating: false });
}
} catch (error) {
setErrors({ ...errors, avatar: 'Failed to generate avatars. Please try again.' });
setAvatarInfo({ ...avatarInfo, isGenerating: false });
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!validateStep(2)) {
return;
}
if (avatarInfo.selectedAvatarIndex === null) {
setErrors({ ...errors, avatar: 'Please select an avatar' });
return;
}
setErrors({});
try {
const result = await onCompleteOnboarding({
firstName: personalInfo.firstName.trim(),
lastName: personalInfo.lastName.trim(),
displayName: personalInfo.displayName.trim(),
country: personalInfo.country,
timezone: personalInfo.timezone || undefined,
});
if (result.success) {
onCompleted();
} else {
setErrors({ submit: result.error || 'Failed to create profile' });
}
} catch (error) {
setErrors({ submit: 'Failed to create profile' });
}
};
const header = (
<Stack direction="row" align="center" justify="between">
<Stack gap={1}>
<Text size="xl" weight="bold" color="text-white" uppercase letterSpacing="tighter">
GridPilot <Text color="text-primary-blue">Onboarding</Text>
</Text>
<Text size="xs" color="text-gray-500" uppercase letterSpacing="widest">
System Initialization
</Text>
</Stack>
<Box w="64">
<OnboardingStepper currentStep={step} totalSteps={2} steps={steps} />
</Box>
</Stack>
);
const sidebar = (
<Stack gap={6}>
<OnboardingHelpPanel title="Onboarding Process">
Welcome to GridPilot. We&apos;re setting up your racing identity. This process ensures you&apos;re ready for the track with a complete profile and a unique AI-generated avatar.
</OnboardingHelpPanel>
{step === 2 && (
<OnboardingHelpPanel title="Avatar Generation">
Our AI uses your photo to create a professional racing avatar. For best results, use a clear, front-facing photo with good lighting.
</OnboardingHelpPanel>
)}
</Stack>
);
return (
<OnboardingShell header={header} sidebar={sidebar}>
<Box as="form" onSubmit={handleSubmit}>
{step === 1 && (
<OnboardingStepPanel
title="Personal Information"
description="Tell us a bit about yourself to get started."
>
<PersonalInfoStep
personalInfo={personalInfo}
setPersonalInfo={setPersonalInfo}
errors={errors}
loading={isProcessing}
/>
</OnboardingStepPanel>
)}
{step === 2 && (
<OnboardingStepPanel
title="Create Your Racing Avatar"
description="Upload a photo and we will generate a unique racing avatar for you."
>
<AvatarStep
avatarInfo={avatarInfo}
setAvatarInfo={setAvatarInfo}
errors={errors}
setErrors={setErrors}
onGenerateAvatars={generateAvatars}
/>
</OnboardingStepPanel>
)}
{errors.submit && (
<Box mt={4}>
<OnboardingError message={errors.submit} />
</Box>
)}
<OnboardingPrimaryActions
onBack={step > 1 ? handleBack : undefined}
onNext={step < 2 ? handleNext : undefined}
isLastStep={step === 2}
canNext={step === 1 ? true : avatarInfo.selectedAvatarIndex !== null}
isLoading={isProcessing}
type={step === 2 ? 'submit' : 'button'}
/>
</Box>
</OnboardingShell>
);
}