website refactor
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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't completed any races yet</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
38
apps/website/templates/FatalErrorTemplate.test.tsx
Normal file
38
apps/website/templates/FatalErrorTemplate.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
28
apps/website/templates/FatalErrorTemplate.tsx
Normal file
28
apps/website/templates/FatalErrorTemplate.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 — 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 — 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'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'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 — 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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
111
apps/website/templates/LeagueOverviewTemplate.tsx
Normal file
111
apps/website/templates/LeagueOverviewTemplate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
24
apps/website/templates/MediaTemplate.tsx
Normal file
24
apps/website/templates/MediaTemplate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
apps/website/templates/NotFoundTemplate.test.tsx
Normal file
31
apps/website/templates/NotFoundTemplate.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
34
apps/website/templates/NotFoundTemplate.tsx
Normal file
34
apps/website/templates/NotFoundTemplate.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
28
apps/website/templates/ProfileLayoutShellTemplate.tsx
Normal file
28
apps/website/templates/ProfileLayoutShellTemplate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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'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>
|
||||
);
|
||||
}
|
||||
|
||||
39
apps/website/templates/ProfileLiveriesTemplate.tsx
Normal file
39
apps/website/templates/ProfileLiveriesTemplate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
apps/website/templates/ProfileSettingsTemplate.tsx
Normal file
68
apps/website/templates/ProfileSettingsTemplate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
apps/website/templates/ProfileSidebarTemplate.tsx
Normal file
77
apps/website/templates/ProfileSidebarTemplate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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're looking for doesn'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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're looking for doesn'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}>"{req.message}"</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
77
apps/website/templates/ServerErrorTemplate.tsx
Normal file
77
apps/website/templates/ServerErrorTemplate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
216
apps/website/templates/SponsorBillingTemplate.tsx
Normal file
216
apps/website/templates/SponsorBillingTemplate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
apps/website/templates/SponsorCampaignsTemplate.tsx
Normal file
168
apps/website/templates/SponsorCampaignsTemplate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}>
|
||||
|
||||
208
apps/website/templates/SponsorSettingsTemplate.tsx
Normal file
208
apps/website/templates/SponsorSettingsTemplate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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're looking for doesn'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
21
apps/website/templates/actions/ActionsTemplate.tsx
Normal file
21
apps/website/templates/actions/ActionsTemplate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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'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'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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
|
||||
85
apps/website/templates/layout/GlobalFooterTemplate.tsx
Normal file
85
apps/website/templates/layout/GlobalFooterTemplate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
apps/website/templates/layout/GlobalSidebarTemplate.tsx
Normal file
77
apps/website/templates/layout/GlobalSidebarTemplate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
apps/website/templates/layout/HeaderContentTemplate.tsx
Normal file
53
apps/website/templates/layout/HeaderContentTemplate.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
45
apps/website/templates/layout/RootAppShellTemplate.tsx
Normal file
45
apps/website/templates/layout/RootAppShellTemplate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
267
apps/website/templates/onboarding/OnboardingTemplate.tsx
Normal file
267
apps/website/templates/onboarding/OnboardingTemplate.tsx
Normal 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're setting up your racing identity. This process ensures you'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user