website refactor
This commit is contained in:
@@ -1,12 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { Layout } from '@/ui/Layout';
|
||||
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';
|
||||
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 {
|
||||
Users,
|
||||
Shield,
|
||||
@@ -21,122 +29,123 @@ import {
|
||||
* Pure template for admin dashboard.
|
||||
* Accepts ViewData only, no business logic.
|
||||
*/
|
||||
export function AdminDashboardTemplate(props: {
|
||||
adminDashboardViewData: AdminDashboardViewData;
|
||||
export function AdminDashboardTemplate({
|
||||
viewData,
|
||||
onRefresh,
|
||||
isLoading
|
||||
}: {
|
||||
viewData: AdminDashboardViewData;
|
||||
onRefresh: () => void;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const { adminDashboardViewData: viewData, onRefresh, isLoading } = props;
|
||||
|
||||
return (
|
||||
<Layout padding="p-6" gap="gap-6" className="container mx-auto">
|
||||
{/* Header */}
|
||||
<Layout flex flexCol={false} items="center" justify="between">
|
||||
<div>
|
||||
<Text size="2xl" weight="bold" color="text-white">
|
||||
Admin Dashboard
|
||||
</Text>
|
||||
<Text size="sm" color="text-gray-400" className="mt-1">
|
||||
System overview and statistics
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</Layout>
|
||||
<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} className={isLoading ? 'animate-spin' : ''} />}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<Layout grid gridCols={4} gap="gap-4">
|
||||
<StatCard
|
||||
label="Total Users"
|
||||
value={viewData.stats.totalUsers}
|
||||
icon={<Users className="w-8 h-8" />}
|
||||
variant="blue"
|
||||
/>
|
||||
<StatCard
|
||||
label="Admins"
|
||||
value={viewData.stats.systemAdmins}
|
||||
icon={<Shield className="w-8 h-8" />}
|
||||
variant="purple"
|
||||
/>
|
||||
<StatCard
|
||||
label="Active Users"
|
||||
value={viewData.stats.activeUsers}
|
||||
icon={<Activity className="w-8 h-8" />}
|
||||
variant="green"
|
||||
/>
|
||||
<StatCard
|
||||
label="Recent Logins"
|
||||
value={viewData.stats.recentLogins}
|
||||
icon={<Clock className="w-8 h-8" />}
|
||||
variant="orange"
|
||||
/>
|
||||
</Layout>
|
||||
{/* 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>
|
||||
|
||||
{/* System Status */}
|
||||
<Card>
|
||||
<Text size="lg" weight="semibold" color="text-white" className="mb-4">
|
||||
System Status
|
||||
</Text>
|
||||
<Layout flex flexCol gap="gap-4">
|
||||
<Layout flex flexCol={false} items="center" justify="between">
|
||||
<Text size="sm" color="text-gray-400">
|
||||
System Health
|
||||
</Text>
|
||||
<StatusBadge variant="success">
|
||||
Healthy
|
||||
</StatusBadge>
|
||||
</Layout>
|
||||
<Layout flex flexCol={false} items="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>
|
||||
</Layout>
|
||||
<Layout flex flexCol={false} items="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>
|
||||
</Layout>
|
||||
<Layout flex flexCol={false} items="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>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Card>
|
||||
{/* 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>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<Text size="lg" weight="semibold" color="text-white" className="mb-4">
|
||||
Quick Actions
|
||||
</Text>
|
||||
<Layout grid gridCols={3} gap="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>
|
||||
</Layout>
|
||||
</Card>
|
||||
</Layout>
|
||||
{/* 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>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Select } from '@/ui/Select';
|
||||
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 {
|
||||
Search,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
Users,
|
||||
Shield,
|
||||
Trash2,
|
||||
AlertTriangle
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
|
||||
import { UserFilters } from '@/components/admin/UserFilters';
|
||||
import { UserStatsSummary } from '@/components/admin/UserStatsSummary';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
/**
|
||||
* AdminUsersTemplate
|
||||
*
|
||||
* Pure template for admin users page.
|
||||
* Accepts ViewData only, no business logic.
|
||||
*/
|
||||
export function AdminUsersTemplate(props: {
|
||||
adminUsersViewData: AdminUsersViewData;
|
||||
interface AdminUsersTemplateProps {
|
||||
viewData: AdminUsersViewData;
|
||||
onRefresh: () => void;
|
||||
onSearch: (search: string) => void;
|
||||
onFilterRole: (role: string) => void;
|
||||
@@ -37,309 +38,216 @@ export function AdminUsersTemplate(props: {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
deletingUser: string | null;
|
||||
}) {
|
||||
const {
|
||||
adminUsersViewData: viewData,
|
||||
onRefresh,
|
||||
onSearch,
|
||||
onFilterRole,
|
||||
onFilterStatus,
|
||||
onClearFilters,
|
||||
onUpdateStatus,
|
||||
onDeleteUser,
|
||||
search,
|
||||
roleFilter,
|
||||
statusFilter,
|
||||
loading,
|
||||
error,
|
||||
deletingUser
|
||||
} = props;
|
||||
}
|
||||
|
||||
const toStatusBadgeProps = (
|
||||
status: string,
|
||||
): { status: 'success' | 'warning' | 'error' | 'neutral'; label: string } => {
|
||||
export function AdminUsersTemplate({
|
||||
viewData,
|
||||
onRefresh,
|
||||
onSearch,
|
||||
onFilterRole,
|
||||
onFilterStatus,
|
||||
onClearFilters,
|
||||
onUpdateStatus,
|
||||
onDeleteUser,
|
||||
search,
|
||||
roleFilter,
|
||||
statusFilter,
|
||||
loading,
|
||||
error,
|
||||
deletingUser
|
||||
}: AdminUsersTemplateProps) {
|
||||
const getStatusBadgeVariant = (status: string): 'success' | 'warning' | 'error' | 'info' => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return { status: 'success', label: 'Active' };
|
||||
case 'suspended':
|
||||
return { status: 'warning', label: 'Suspended' };
|
||||
case 'deleted':
|
||||
return { status: 'error', label: 'Deleted' };
|
||||
default:
|
||||
return { status: 'neutral', label: status };
|
||||
case 'active': return 'success';
|
||||
case 'suspended': return 'warning';
|
||||
case 'deleted': return 'error';
|
||||
default: return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleBadgeClass = (role: string) => {
|
||||
const getRoleBadgeStyle = (role: string) => {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return 'bg-purple-500/20 text-purple-300 border border-purple-500/30';
|
||||
case 'admin':
|
||||
return 'bg-blue-500/20 text-blue-300 border border-blue-500/30';
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-300 border border-gray-500/30';
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleBadgeLabel = (role: string) => {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return 'Owner';
|
||||
case 'admin':
|
||||
return 'Admin';
|
||||
case 'user':
|
||||
return 'User';
|
||||
default:
|
||||
return role;
|
||||
case 'owner': return { backgroundColor: 'rgba(168, 85, 247, 0.2)', color: '#d8b4fe', border: '1px solid rgba(168, 85, 247, 0.3)' };
|
||||
case 'admin': return { backgroundColor: 'rgba(59, 130, 246, 0.2)', color: '#93c5fd', border: '1px solid rgba(59, 130, 246, 0.3)' };
|
||||
default: return { backgroundColor: 'rgba(115, 115, 115, 0.2)', color: '#d1d5db', border: '1px solid rgba(115, 115, 115, 0.3)' };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Text size="2xl" weight="bold" color="text-white">User Management</Text>
|
||||
<Text size="sm" color="text-gray-400" className="mt-1">Manage and monitor all system users</Text>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="bg-racing-red/10 border border-racing-red text-racing-red px-4 py-3 rounded-lg flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<Text weight="medium">Error</Text>
|
||||
<Text size="sm" className="opacity-90">{error}</Text>
|
||||
</div>
|
||||
<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={() => {}}
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
variant="secondary"
|
||||
className="text-racing-red hover:opacity-70 p-0"
|
||||
icon={<Icon icon={RefreshCw} size={4} className={loading ? 'animate-spin' : ''} />}
|
||||
>
|
||||
×
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Filters Card */}
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<Text weight="medium" color="text-white">Filters</Text>
|
||||
</div>
|
||||
{(search || roleFilter || statusFilter) && (
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<InfoBox
|
||||
icon={Users}
|
||||
title="Error"
|
||||
description={error}
|
||||
variant="warning"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Filters Card */}
|
||||
<UserFilters
|
||||
search={search}
|
||||
roleFilter={roleFilter}
|
||||
statusFilter={statusFilter}
|
||||
onSearch={onSearch}
|
||||
onFilterRole={onFilterRole}
|
||||
onFilterStatus={onFilterStatus}
|
||||
onClearFilters={onClearFilters}
|
||||
/>
|
||||
|
||||
{/* Users Table */}
|
||||
<Card p={0}>
|
||||
{loading ? (
|
||||
<Stack center py={12} gap={3}>
|
||||
<Box className="animate-spin" style={{ borderRadius: '9999px', height: '2rem', width: '2rem', borderBottom: '2px solid #3b82f6' }} />
|
||||
<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="secondary"
|
||||
className="text-xs p-0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
Clear all
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by email or name..."
|
||||
value={search}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={roleFilter}
|
||||
onChange={(e) => onFilterRole(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'All Roles' },
|
||||
{ value: 'owner', label: 'Owner' },
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'user', label: 'User' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => onFilterStatus(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'All Status' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'deleted', label: 'Deleted' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Users Table */}
|
||||
<Card>
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 space-y-3">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-blue"></div>
|
||||
<Text color="text-gray-400">Loading users...</Text>
|
||||
</div>
|
||||
) : !viewData.users || viewData.users.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 space-y-3">
|
||||
<Users className="w-12 h-12 text-gray-600" />
|
||||
<Text color="text-gray-400">No users found</Text>
|
||||
<Button
|
||||
onClick={onClearFilters}
|
||||
variant="secondary"
|
||||
className="text-sm p-0"
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<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, index: number) => (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className={index % 2 === 0 ? 'bg-transparent' : 'bg-iron-gray/10'}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary-blue/20 flex items-center justify-center">
|
||||
<Shield className="w-4 h-4 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-white">{user.displayName}</div>
|
||||
<div className="text-xs text-gray-500">ID: {user.id}</div>
|
||||
{user.primaryDriverId && (
|
||||
<div className="text-xs text-gray-500">Driver: {user.primaryDriverId}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm text-gray-300">{user.email}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.roles.map((role: string, idx: number) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={`px-2 py-1 text-xs rounded-full font-medium ${getRoleBadgeClass(role)}`}
|
||||
>
|
||||
{getRoleBadgeLabel(role)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
const badge = toStatusBadgeProps(user.status);
|
||||
return <StatusBadge status={badge.status} label={badge.label} />;
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm text-gray-400">
|
||||
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : 'Never'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{user.status === 'active' && (
|
||||
<Button
|
||||
onClick={() => onUpdateStatus(user.id, 'suspended')}
|
||||
variant="secondary"
|
||||
className="px-3 py-1 text-xs bg-yellow-500/20 text-yellow-300 hover:bg-yellow-500/30"
|
||||
>
|
||||
Suspend
|
||||
</Button>
|
||||
)}
|
||||
{user.status === 'suspended' && (
|
||||
<Button
|
||||
onClick={() => onUpdateStatus(user.id, 'active')}
|
||||
variant="secondary"
|
||||
className="px-3 py-1 text-xs bg-performance-green/20 text-performance-green hover:bg-performance-green/30"
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
)}
|
||||
{user.status !== 'deleted' && (
|
||||
<Button
|
||||
onClick={() => onDeleteUser(user.id)}
|
||||
disabled={deletingUser === user.id}
|
||||
variant="secondary"
|
||||
className="px-3 py-1 text-xs bg-racing-red/20 text-racing-red hover:bg-racing-red/30 flex items-center gap-1"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{deletingUser === user.id ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</Stack>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>User</TableHeader>
|
||||
<TableHeader>Email</TableHeader>
|
||||
<TableHeader>Roles</TableHeader>
|
||||
<TableHeader>Status</TableHeader>
|
||||
<TableHeader>Last Login</TableHeader>
|
||||
<TableHeader>Actions</TableHeader>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</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} style={{ backgroundColor: 'rgba(59, 130, 246, 0.2)' }}>
|
||||
<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 style = getRoleBadgeStyle(role);
|
||||
return (
|
||||
<Surface
|
||||
key={idx}
|
||||
variant="muted"
|
||||
rounded="full"
|
||||
padding={1}
|
||||
style={{
|
||||
paddingLeft: '0.5rem',
|
||||
paddingRight: '0.5rem',
|
||||
backgroundColor: style.backgroundColor,
|
||||
color: style.color,
|
||||
borderColor: style.border,
|
||||
border: '1px solid'
|
||||
}}
|
||||
>
|
||||
<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 ? new Date(user.lastLoginAt).toLocaleDateString() : '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>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Stats Summary */}
|
||||
{viewData.users.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="bg-gradient-to-br from-blue-900/20 to-blue-700/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Text size="sm" color="text-gray-400" className="mb-1">Total Users</Text>
|
||||
<Text size="2xl" weight="bold" color="text-white">{viewData.total}</Text>
|
||||
</div>
|
||||
<Users className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-green-900/20 to-green-700/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Text size="sm" color="text-gray-400" className="mb-1">Active</Text>
|
||||
<Text size="2xl" weight="bold" color="text-white">
|
||||
{viewData.activeUserCount}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="w-6 h-6 text-green-400">✓</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-purple-900/20 to-purple-700/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Text size="sm" color="text-gray-400" className="mb-1">Admins</Text>
|
||||
<Text size="2xl" weight="bold" color="text-white">
|
||||
{viewData.adminCount}
|
||||
</Text>
|
||||
</div>
|
||||
<Shield className="w-6 h-6 text-purple-400" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Stats Summary */}
|
||||
{viewData.users.length > 0 && (
|
||||
<UserStatsSummary
|
||||
total={viewData.total}
|
||||
activeCount={viewData.activeUserCount}
|
||||
adminCount={viewData.adminCount}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import type { DashboardViewData } from '@/lib/view-data/DashboardViewData';
|
||||
import {
|
||||
Trophy,
|
||||
Medal,
|
||||
Target,
|
||||
Users,
|
||||
ChevronRight,
|
||||
Calendar,
|
||||
Clock,
|
||||
Activity,
|
||||
Award,
|
||||
UserPlus,
|
||||
Flag,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
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/DashboardHero';
|
||||
import { NextRaceCard } from '@/components/dashboard/NextRaceCard';
|
||||
import { ChampionshipStandings } from '@/components/dashboard/ChampionshipStandings';
|
||||
import { ActivityFeed } from '@/components/dashboard/ActivityFeed';
|
||||
import { UpcomingRaces } from '@/components/dashboard/UpcomingRaces';
|
||||
import { FriendsSidebar } from '@/components/dashboard/FriendsSidebar';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
|
||||
interface DashboardTemplateProps {
|
||||
viewData: DashboardViewData;
|
||||
@@ -27,7 +27,6 @@ export function DashboardTemplate({ viewData }: DashboardTemplateProps) {
|
||||
feedItems,
|
||||
friends,
|
||||
activeLeaguesCount,
|
||||
friendCount,
|
||||
hasUpcomingRaces,
|
||||
hasLeagueStandings,
|
||||
hasFeedItems,
|
||||
@@ -35,312 +34,32 @@ export function DashboardTemplate({ viewData }: DashboardTemplateProps) {
|
||||
} = viewData;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-deep-graphite">
|
||||
{/* Hero Section */}
|
||||
<section className="relative overflow-hidden">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/10 via-deep-graphite to-purple-600/5" />
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
||||
}} />
|
||||
</div>
|
||||
<Box as="main">
|
||||
<DashboardHero
|
||||
currentDriver={currentDriver}
|
||||
activeLeaguesCount={activeLeaguesCount}
|
||||
/>
|
||||
|
||||
<div className="relative max-w-7xl mx-auto px-6 py-10">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8">
|
||||
{/* Welcome Message */}
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="relative">
|
||||
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-primary-blue to-purple-600 p-0.5 shadow-xl shadow-primary-blue/20">
|
||||
<div className="w-full h-full rounded-xl overflow-hidden bg-iron-gray">
|
||||
<img
|
||||
src={currentDriver.avatarUrl}
|
||||
alt={currentDriver.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-performance-green border-3 border-deep-graphite" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm mb-1">Good morning,</p>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2">
|
||||
{currentDriver.name}
|
||||
<span className="ml-3 text-2xl">{currentDriver.country}</span>
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary-blue/10 border border-primary-blue/30">
|
||||
<span className="text-sm font-semibold text-primary-blue">{currentDriver.rating}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-yellow-400/10 border border-yellow-400/30">
|
||||
<span className="text-sm font-semibold text-yellow-400">#{currentDriver.rank}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{currentDriver.totalRaces} races completed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<a href="/leagues" className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<span>Flag</span>
|
||||
Browse Leagues
|
||||
</a>
|
||||
<a href=routes.protected.profile className="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg text-white text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<span>Activity</span>
|
||||
View Profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats Row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
|
||||
<div className="p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/20 text-performance-green">
|
||||
<span>Trophy</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{currentDriver.wins}</p>
|
||||
<p className="text-xs text-gray-500">Wins</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/20 text-warning-amber">
|
||||
<span>Medal</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{currentDriver.podiums}</p>
|
||||
<p className="text-xs text-gray-500">Podiums</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/20 text-primary-blue">
|
||||
<span>Target</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{currentDriver.consistency}</p>
|
||||
<p className="text-xs text-gray-500">Consistency</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/20 text-purple-400">
|
||||
<span>Users</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{activeLeaguesCount}</p>
|
||||
<p className="text-xs text-gray-500">Active Leagues</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main Content */}
|
||||
<section className="max-w-7xl mx-auto px-6 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Container size="lg" py={8}>
|
||||
<Grid cols={12} gap={6}>
|
||||
{/* Left Column - Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Next Race Card */}
|
||||
{nextRace && (
|
||||
<div className="relative overflow-hidden bg-gradient-to-br from-iron-gray to-iron-gray/80 border border-primary-blue/30 rounded-xl p-6">
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-bl from-primary-blue/20 to-transparent rounded-bl-full" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-primary-blue/20 border border-primary-blue/30">
|
||||
<span className="text-xs font-semibold text-primary-blue uppercase tracking-wider">Next Race</span>
|
||||
</div>
|
||||
{nextRace.isMyLeague && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-performance-green/20 text-performance-green text-xs font-medium">
|
||||
Your League
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">{nextRace.track}</h2>
|
||||
<p className="text-gray-400 mb-3">{nextRace.car}</p>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1.5 text-gray-400">
|
||||
<span>Calendar</span>
|
||||
{nextRace.formattedDate}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-gray-400">
|
||||
<span>Clock</span>
|
||||
{nextRace.formattedTime}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-3">
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Starts in</p>
|
||||
<p className="text-3xl font-bold text-primary-blue font-mono">{nextRace.timeUntil}</p>
|
||||
</div>
|
||||
<a href={`/races/${nextRace.id}`} className="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg text-white text-sm font-medium transition-colors flex items-center gap-2">
|
||||
View Details
|
||||
<span>ChevronRight</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* League Standings Preview */}
|
||||
{hasLeagueStandings && (
|
||||
<div className="bg-iron-gray/30 border border-charcoal-outline rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<span>Award</span>
|
||||
Your Championship Standings
|
||||
</h2>
|
||||
<a href=routes.protected.profileLeagues className="text-sm text-primary-blue hover:underline flex items-center gap-1">
|
||||
View all <span>ChevronRight</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{leagueStandings.map((summary) => (
|
||||
<div key={summary.leagueId} className="flex items-center justify-between p-3 bg-deep-graphite rounded-lg">
|
||||
<div>
|
||||
<p className="text-white font-medium">{summary.leagueName}</p>
|
||||
<p className="text-xs text-gray-500">Position {summary.position} • {summary.points} points</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{summary.totalDrivers} drivers</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activity Feed */}
|
||||
<div className="bg-iron-gray/30 border border-charcoal-outline rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<span>Activity</span>
|
||||
Recent Activity
|
||||
</h2>
|
||||
</div>
|
||||
{hasFeedItems ? (
|
||||
<div className="space-y-4">
|
||||
{feedItems.slice(0, 5).map((item) => (
|
||||
<div key={item.id} className="flex items-start gap-3 p-3 bg-deep-graphite rounded-lg">
|
||||
<div className="flex-1">
|
||||
<p className="text-white font-medium">{item.headline}</p>
|
||||
{item.body && <p className="text-sm text-gray-400 mt-1">{item.body}</p>}
|
||||
<p className="text-xs text-gray-500 mt-1">{item.formattedTime}</p>
|
||||
</div>
|
||||
{item.ctaHref && item.ctaLabel && (
|
||||
<a href={item.ctaHref} className="text-xs text-primary-blue hover:underline">
|
||||
{item.ctaLabel}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<span className="text-4xl text-gray-600 mx-auto mb-3">Activity</span>
|
||||
<p className="text-gray-400 mb-2">No activity yet</p>
|
||||
<p className="text-sm text-gray-500">Join leagues and add friends to see activity here</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<GridItem colSpan={12} lgSpan={8}>
|
||||
<Stack gap={6}>
|
||||
{nextRace && <NextRaceCard nextRace={nextRace} />}
|
||||
{hasLeagueStandings && <ChampionshipStandings standings={leagueStandings} />}
|
||||
<ActivityFeed items={feedItems} hasItems={hasFeedItems} />
|
||||
</Stack>
|
||||
</GridItem>
|
||||
|
||||
{/* Right Column - Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Upcoming Races */}
|
||||
<div className="bg-iron-gray/30 border border-charcoal-outline rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<span>Calendar</span>
|
||||
Upcoming Races
|
||||
</h3>
|
||||
<a href="/races" className="text-xs text-primary-blue hover:underline">
|
||||
View all
|
||||
</a>
|
||||
</div>
|
||||
{hasUpcomingRaces ? (
|
||||
<div className="space-y-3">
|
||||
{upcomingRaces.slice(0, 5).map((race) => (
|
||||
<div key={race.id} className="p-3 bg-deep-graphite rounded-lg">
|
||||
<p className="text-white font-medium">{race.track}</p>
|
||||
<p className="text-sm text-gray-400">{race.car}</p>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
|
||||
<span>{race.formattedDate}</span>
|
||||
<span>•</span>
|
||||
<span>{race.formattedTime}</span>
|
||||
</div>
|
||||
{race.isMyLeague && (
|
||||
<span className="inline-block mt-1 px-2 py-0.5 rounded-full bg-performance-green/20 text-performance-green text-xs font-medium">
|
||||
Your League
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm text-center py-4">No upcoming races</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Friends */}
|
||||
<div className="bg-iron-gray/30 border border-charcoal-outline rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<span>Users</span>
|
||||
Friends
|
||||
</h3>
|
||||
<span className="text-xs text-gray-500">{friends.length} friends</span>
|
||||
</div>
|
||||
{hasFriends ? (
|
||||
<div className="space-y-2">
|
||||
{friends.slice(0, 6).map((friend) => (
|
||||
<div key={friend.id} className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite transition-colors">
|
||||
<div className="w-9 h-9 rounded-full overflow-hidden bg-gradient-to-br from-primary-blue to-purple-600">
|
||||
<img
|
||||
src={friend.avatarUrl}
|
||||
alt={friend.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm font-medium truncate">{friend.name}</p>
|
||||
<p className="text-xs text-gray-500">{friend.country}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{friends.length > 6 && (
|
||||
<a
|
||||
href=routes.protected.profile
|
||||
className="block text-center py-2 text-sm text-primary-blue hover:underline"
|
||||
>
|
||||
+{friends.length - 6} more
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6">
|
||||
<span className="text-3xl text-gray-600 mx-auto mb-2">UserPlus</span>
|
||||
<p className="text-sm text-gray-400 mb-2">No friends yet</p>
|
||||
<a href="/drivers" className="px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded text-xs text-white transition-colors">
|
||||
Find Drivers
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<GridItem colSpan={12} lgSpan={4}>
|
||||
<Stack gap={6}>
|
||||
<UpcomingRaces races={upcomingRaces} hasRaces={hasUpcomingRaces} />
|
||||
<FriendsSidebar friends={friends} hasFriends={hasFriends} />
|
||||
</Stack>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,816 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import {
|
||||
User,
|
||||
Trophy,
|
||||
Star,
|
||||
Calendar,
|
||||
Users,
|
||||
Flag,
|
||||
Award,
|
||||
TrendingUp,
|
||||
UserPlus,
|
||||
ExternalLink,
|
||||
Target,
|
||||
Zap,
|
||||
Clock,
|
||||
Medal,
|
||||
Crown,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
Twitter,
|
||||
Youtube,
|
||||
Twitch,
|
||||
MessageCircle,
|
||||
ArrowLeft,
|
||||
BarChart3,
|
||||
Shield,
|
||||
Percent,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { LoadingSpinner } from '@/ui/LoadingSpinner';
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import { CircularProgress } from '@/components/drivers/CircularProgress';
|
||||
import { HorizontalBarChart } from '@/components/drivers/HorizontalBarChart';
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
import type { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel';
|
||||
import { ProfileHero } from '@/components/profile/ProfileHero';
|
||||
import { ProfileBio } from '@/components/profile/ProfileBio';
|
||||
import { TeamMembershipGrid } from '@/components/profile/TeamMembershipGrid';
|
||||
import { PerformanceOverview } from '@/components/profile/PerformanceOverview';
|
||||
import { ProfileTabs } from '@/components/profile/ProfileTabs';
|
||||
import { CareerStats } from '@/components/profile/CareerStats';
|
||||
import { RacingProfile } from '@/components/profile/RacingProfile';
|
||||
import { AchievementGrid } from '@/components/profile/AchievementGrid';
|
||||
import { FriendsPreview } from '@/components/profile/FriendsPreview';
|
||||
import type { DriverProfileViewData } from '../../../lib/types/view-data/DriverProfileViewData';
|
||||
|
||||
type ProfileTab = 'overview' | 'stats';
|
||||
|
||||
interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface SocialHandle {
|
||||
platform: 'twitter' | 'youtube' | 'twitch' | 'discord';
|
||||
handle: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Achievement {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
|
||||
rarity: 'common' | 'rare' | 'epic' | 'legendary';
|
||||
earnedAt: Date;
|
||||
}
|
||||
|
||||
interface DriverExtendedProfile {
|
||||
socialHandles: SocialHandle[];
|
||||
achievements: Achievement[];
|
||||
racingStyle: string;
|
||||
favoriteTrack: string;
|
||||
favoriteCar: string;
|
||||
timezone: string;
|
||||
availableHours: string;
|
||||
lookingForTeam: boolean;
|
||||
openToRequests: boolean;
|
||||
}
|
||||
|
||||
interface TeamMembershipInfo {
|
||||
team: Team;
|
||||
role: string;
|
||||
joinedAt: Date;
|
||||
}
|
||||
|
||||
interface DriverProfileTemplateProps {
|
||||
driverProfile: DriverProfileViewModel;
|
||||
allTeamMemberships: TeamMembershipInfo[];
|
||||
viewData: DriverProfileViewData;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
onBackClick: () => void;
|
||||
onAddFriend: () => void;
|
||||
friendRequestSent: boolean;
|
||||
activeTab: ProfileTab;
|
||||
setActiveTab: (tab: ProfileTab) => void;
|
||||
onTabChange: (tab: ProfileTab) => void;
|
||||
isSponsorMode?: boolean;
|
||||
sponsorInsights?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function getCountryFlag(countryCode: string): string {
|
||||
const code = countryCode.toUpperCase();
|
||||
if (code.length === 2) {
|
||||
const codePoints = [...code].map(char => 127397 + char.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
}
|
||||
return '🏁';
|
||||
}
|
||||
|
||||
function getRarityColor(rarity: Achievement['rarity']) {
|
||||
switch (rarity) {
|
||||
case 'common':
|
||||
return 'text-gray-400 bg-gray-400/10 border-gray-400/30';
|
||||
case 'rare':
|
||||
return 'text-primary-blue bg-primary-blue/10 border-primary-blue/30';
|
||||
case 'epic':
|
||||
return 'text-purple-400 bg-purple-400/10 border-purple-400/30';
|
||||
case 'legendary':
|
||||
return 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30';
|
||||
}
|
||||
}
|
||||
|
||||
function getAchievementIcon(icon: Achievement['icon']) {
|
||||
switch (icon) {
|
||||
case 'trophy':
|
||||
return Trophy;
|
||||
case 'medal':
|
||||
return Medal;
|
||||
case 'star':
|
||||
return Star;
|
||||
case 'crown':
|
||||
return Crown;
|
||||
case 'target':
|
||||
return Target;
|
||||
case 'zap':
|
||||
return Zap;
|
||||
}
|
||||
}
|
||||
|
||||
function getSocialIcon(platform: SocialHandle['platform']) {
|
||||
switch (platform) {
|
||||
case 'twitter':
|
||||
return Twitter;
|
||||
case 'youtube':
|
||||
return Youtube;
|
||||
case 'twitch':
|
||||
return Twitch;
|
||||
case 'discord':
|
||||
return MessageCircle;
|
||||
}
|
||||
}
|
||||
|
||||
function getSocialColor(platform: SocialHandle['platform']) {
|
||||
switch (platform) {
|
||||
case 'twitter':
|
||||
return 'hover:text-sky-400 hover:bg-sky-400/10';
|
||||
case 'youtube':
|
||||
return 'hover:text-red-500 hover:bg-red-500/10';
|
||||
case 'twitch':
|
||||
return 'hover:text-purple-400 hover:bg-purple-400/10';
|
||||
case 'discord':
|
||||
return 'hover:text-indigo-400 hover:bg-indigo-400/10';
|
||||
}
|
||||
}
|
||||
|
||||
export function DriverProfileTemplate({
|
||||
driverProfile,
|
||||
allTeamMemberships,
|
||||
viewData,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
onBackClick,
|
||||
onAddFriend,
|
||||
friendRequestSent,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
onTabChange,
|
||||
isSponsorMode = false,
|
||||
sponsorInsights = null,
|
||||
}: DriverProfileTemplateProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-gray-400">Loading driver profile...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Container size="lg" py={12}>
|
||||
<Stack align="center" justify="center" gap={4}>
|
||||
<LoadingSpinner size={10} />
|
||||
<Text color="text-gray-400">Loading driver profile...</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !driverProfile?.currentDriver) {
|
||||
if (error || !viewData?.currentDriver) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<Card className="text-center py-12">
|
||||
<User className="w-16 h-16 text-gray-600 mx-auto mb-4" />
|
||||
<div className="text-warning-amber mb-4">{error || 'Driver not found'}</div>
|
||||
<Container size="md" py={12}>
|
||||
<Stack align="center" gap={6}>
|
||||
<Text color="text-warning-amber">{error || 'Driver not found'}</Text>
|
||||
<Button variant="secondary" onClick={onBackClick}>
|
||||
Back to Drivers
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const extendedProfile: DriverExtendedProfile = driverProfile.extendedProfile ? {
|
||||
socialHandles: driverProfile.extendedProfile.socialHandles,
|
||||
achievements: driverProfile.extendedProfile.achievements.map((achievement) => ({
|
||||
id: achievement.id,
|
||||
title: achievement.title,
|
||||
description: achievement.description,
|
||||
icon: achievement.icon,
|
||||
rarity: achievement.rarity,
|
||||
earnedAt: new Date(achievement.earnedAt),
|
||||
})),
|
||||
racingStyle: driverProfile.extendedProfile.racingStyle,
|
||||
favoriteTrack: driverProfile.extendedProfile.favoriteTrack,
|
||||
favoriteCar: driverProfile.extendedProfile.favoriteCar,
|
||||
timezone: driverProfile.extendedProfile.timezone,
|
||||
availableHours: driverProfile.extendedProfile.availableHours,
|
||||
lookingForTeam: driverProfile.extendedProfile.lookingForTeam,
|
||||
openToRequests: driverProfile.extendedProfile.openToRequests,
|
||||
} : {
|
||||
socialHandles: [],
|
||||
achievements: [],
|
||||
racingStyle: 'Unknown',
|
||||
favoriteTrack: 'Unknown',
|
||||
favoriteCar: 'Unknown',
|
||||
timezone: 'UTC',
|
||||
availableHours: 'Flexible',
|
||||
lookingForTeam: false,
|
||||
openToRequests: false,
|
||||
};
|
||||
|
||||
const stats = driverProfile?.stats || null;
|
||||
const globalRank = driverProfile?.currentDriver?.globalRank || 1;
|
||||
const driver = driverProfile.currentDriver;
|
||||
const { currentDriver, stats, teamMemberships, socialSummary, extendedProfile } = viewData;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 pb-12 space-y-6">
|
||||
{/* Back Navigation */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBackClick}
|
||||
className="flex items-center gap-2 mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Drivers
|
||||
</Button>
|
||||
<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: driver.name },
|
||||
]}
|
||||
/>
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Drivers', href: '/drivers' },
|
||||
{ label: currentDriver.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Sponsor Insights Card */}
|
||||
{isSponsorMode && sponsorInsights}
|
||||
{/* Sponsor Insights Card */}
|
||||
{isSponsorMode && sponsorInsights}
|
||||
|
||||
{/* Hero Header Section */}
|
||||
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-iron-gray/80 via-iron-gray/60 to-deep-graphite border border-charcoal-outline">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
||||
}}
|
||||
{/* 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}
|
||||
friendRequestSent={friendRequestSent}
|
||||
/>
|
||||
|
||||
{/* Bio Section */}
|
||||
{currentDriver.bio && <ProfileBio bio={currentDriver.bio} />}
|
||||
|
||||
{/* Team Memberships */}
|
||||
{teamMemberships.length > 0 && (
|
||||
<TeamMembershipGrid
|
||||
memberships={teamMemberships.map((m) => ({
|
||||
team: { id: m.teamId, name: m.teamName },
|
||||
role: m.role,
|
||||
joinedAt: new Date(m.joinedAt)
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative p-6 md:p-8">
|
||||
<div className="flex flex-col md:flex-row md:items-start gap-6">
|
||||
{/* Avatar */}
|
||||
<div className="relative">
|
||||
<div className="w-28 h-28 md:w-36 md:h-36 rounded-2xl bg-gradient-to-br from-primary-blue to-purple-600 p-1 shadow-xl shadow-primary-blue/20">
|
||||
<div className="w-full h-full rounded-xl overflow-hidden bg-iron-gray">
|
||||
<Image
|
||||
src={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
|
||||
alt={driver.name}
|
||||
width={144}
|
||||
height={144}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Driver Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white">{driver.name}</h1>
|
||||
<span className="text-4xl" aria-label={`Country: ${driver.country}`}>
|
||||
{getCountryFlag(driver.country)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Tab Navigation */}
|
||||
<ProfileTabs activeTab={activeTab} onTabChange={onTabChange} />
|
||||
|
||||
{/* Rating and Rank */}
|
||||
<div className="flex flex-wrap items-center gap-4 mb-4">
|
||||
{stats && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/30">
|
||||
<Star className="w-4 h-4 text-primary-blue" />
|
||||
<span className="font-mono font-bold text-primary-blue">{stats.rating}</span>
|
||||
<span className="text-xs text-gray-400">Rating</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-400/10 border border-yellow-400/30">
|
||||
<Trophy className="w-4 h-4 text-yellow-400" />
|
||||
<span className="font-mono font-bold text-yellow-400">#{globalRank}</span>
|
||||
<span className="text-xs text-gray-400">Global</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-400">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Globe className="w-4 h-4" />
|
||||
iRacing: {driver.iracingId}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Joined{' '}
|
||||
{new Date(driver.joinedAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="w-4 h-4" />
|
||||
{extendedProfile.timezone}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onAddFriend}
|
||||
disabled={friendRequestSent}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Handles */}
|
||||
{extendedProfile.socialHandles.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-charcoal-outline/50">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-gray-500 mr-2">Connect:</span>
|
||||
{extendedProfile.socialHandles.map((social: SocialHandle) => {
|
||||
const Icon = getSocialIcon(social.platform);
|
||||
return (
|
||||
<a
|
||||
key={social.platform}
|
||||
href={social.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg bg-iron-gray/50 border border-charcoal-outline text-gray-400 transition-all ${getSocialColor(social.platform)}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="text-sm">{social.handle}</span>
|
||||
<ExternalLink className="w-3 h-3 opacity-50" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bio Section */}
|
||||
{driver.bio && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-primary-blue" />
|
||||
About
|
||||
</h2>
|
||||
<p className="text-gray-300 leading-relaxed">{driver.bio}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Team Memberships */}
|
||||
{allTeamMemberships.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-purple-400" />
|
||||
Team Memberships
|
||||
<span className="text-sm text-gray-500 font-normal">({allTeamMemberships.length})</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{allTeamMemberships.map((membership) => (
|
||||
<Link
|
||||
key={membership.team.id}
|
||||
href={`/teams/${membership.team.id}`}
|
||||
className="flex items-center gap-4 p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline hover:border-purple-400/30 hover:bg-iron-gray/50 transition-all group"
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-600/20 border border-purple-600/30">
|
||||
<Users className="w-6 h-6 text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-semibold truncate group-hover:text-purple-400 transition-colors">
|
||||
{membership.team.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span className="px-2 py-0.5 rounded-full bg-purple-600/20 text-purple-400 capitalize">
|
||||
{membership.role}
|
||||
</span>
|
||||
<span>
|
||||
Since {membership.joinedAt.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500 group-hover:text-purple-400 transition-colors" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Performance Overview with Diagrams */}
|
||||
{stats && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-neon-aqua" />
|
||||
Performance Overview
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* Circular Progress Charts */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex gap-6 mb-4">
|
||||
<CircularProgress
|
||||
value={stats.wins}
|
||||
max={stats.totalRaces}
|
||||
label="Win Rate"
|
||||
color="text-performance-green"
|
||||
/>
|
||||
<CircularProgress
|
||||
value={stats.podiums}
|
||||
max={stats.totalRaces}
|
||||
label="Podium Rate"
|
||||
color="text-warning-amber"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<CircularProgress
|
||||
value={stats.consistency ?? 0}
|
||||
max={100}
|
||||
label="Consistency"
|
||||
color="text-primary-blue"
|
||||
/>
|
||||
<CircularProgress
|
||||
value={stats.totalRaces - stats.dnfs}
|
||||
max={stats.totalRaces}
|
||||
label="Finish Rate"
|
||||
color="text-neon-aqua"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bar chart and key metrics */}
|
||||
<div className="md:col-span-2">
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-4 flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
Results Breakdown
|
||||
</h3>
|
||||
<HorizontalBarChart
|
||||
data={[
|
||||
{ label: 'Wins', value: stats.wins, color: 'bg-performance-green' },
|
||||
{ label: 'Podiums (2nd-3rd)', value: stats.podiums - stats.wins, color: 'bg-warning-amber' },
|
||||
{ label: 'DNFs', value: stats.dnfs, color: 'bg-red-500' },
|
||||
]}
|
||||
maxValue={stats.totalRaces}
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<Stack gap={6}>
|
||||
<CareerStats stats={stats || { totalRaces: 0, wins: 0, podiums: 0, consistency: 0 }} />
|
||||
|
||||
{extendedProfile && (
|
||||
<RacingProfile
|
||||
racingStyle={extendedProfile.racingStyle}
|
||||
favoriteTrack={extendedProfile.favoriteTrack}
|
||||
favoriteCar={extendedProfile.favoriteCar}
|
||||
availableHours={extendedProfile.availableHours}
|
||||
lookingForTeam={extendedProfile.lookingForTeam}
|
||||
openToRequests={extendedProfile.openToRequests}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="w-4 h-4 text-performance-green" />
|
||||
<span className="text-xs text-gray-500 uppercase">Best Finish</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-performance-green">P{stats.bestFinish}</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Target className="w-4 h-4 text-primary-blue" />
|
||||
<span className="text-xs text-gray-500 uppercase">Avg Finish</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-primary-blue">
|
||||
P{(stats.avgFinish ?? 0).toFixed(1)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
{extendedProfile && extendedProfile.achievements.length > 0 && (
|
||||
<AchievementGrid
|
||||
achievements={extendedProfile.achievements.map((a) => ({
|
||||
...a,
|
||||
earnedAt: new Date(a.earnedAt)
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex items-center gap-1 p-1 rounded-xl bg-iron-gray/50 border border-charcoal-outline w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === 'overview'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-iron-gray'
|
||||
}`}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('stats')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === 'stats'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-iron-gray'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
Detailed Stats
|
||||
</button>
|
||||
</div>
|
||||
{socialSummary.friends.length > 0 && (
|
||||
<FriendsPreview friends={socialSummary.friends} />
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<>
|
||||
{/* Stats and Profile Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Career Stats */}
|
||||
<Card className="lg:col-span-2">
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-performance-green" />
|
||||
Career Statistics
|
||||
</h2>
|
||||
{stats ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
|
||||
<div className="text-3xl font-bold text-white mb-1">{stats.totalRaces}</div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wider">Races</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
|
||||
<div className="text-3xl font-bold text-performance-green mb-1">{stats.wins}</div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wider">Wins</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
|
||||
<div className="text-3xl font-bold text-warning-amber mb-1">{stats.podiums}</div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wider">Podiums</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
|
||||
<div className="text-3xl font-bold text-primary-blue mb-1">{stats.consistency}%</div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wider">Consistency</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400 text-sm">No race statistics available yet.</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Racing Preferences */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Flag className="w-5 h-5 text-neon-aqua" />
|
||||
Racing Profile
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wider">Racing Style</span>
|
||||
<p className="text-white font-medium">{extendedProfile.racingStyle}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wider">Favorite Track</span>
|
||||
<p className="text-white font-medium">{extendedProfile.favoriteTrack}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wider">Favorite Car</span>
|
||||
<p className="text-white font-medium">{extendedProfile.favoriteCar}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wider">Available</span>
|
||||
<p className="text-white font-medium">{extendedProfile.availableHours}</p>
|
||||
</div>
|
||||
|
||||
{/* Status badges */}
|
||||
<div className="pt-4 border-t border-charcoal-outline/50 space-y-2">
|
||||
{extendedProfile.lookingForTeam && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-performance-green/10 border border-performance-green/30">
|
||||
<Users className="w-4 h-4 text-performance-green" />
|
||||
<span className="text-sm text-performance-green font-medium">Looking for Team</span>
|
||||
</div>
|
||||
)}
|
||||
{extendedProfile.openToRequests && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-primary-blue/10 border border-primary-blue/30">
|
||||
<UserPlus className="w-4 h-4 text-primary-blue" />
|
||||
<span className="text-sm text-primary-blue font-medium">Open to Friend Requests</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Achievements */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Award className="w-5 h-5 text-yellow-400" />
|
||||
Achievements
|
||||
<span className="ml-auto text-sm text-gray-500">{extendedProfile.achievements.length} earned</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{extendedProfile.achievements.map((achievement: Achievement) => {
|
||||
const Icon = getAchievementIcon(achievement.icon);
|
||||
const rarityClasses = getRarityColor(achievement.rarity);
|
||||
return (
|
||||
<div
|
||||
key={achievement.id}
|
||||
className={`p-4 rounded-xl border ${rarityClasses} transition-all hover:scale-105`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${rarityClasses.split(' ')[1]}`}>
|
||||
<Icon className={`w-5 h-5 ${rarityClasses.split(' ')[0]}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-semibold text-sm">{achievement.title}</p>
|
||||
<p className="text-gray-400 text-xs mt-0.5">{achievement.description}</p>
|
||||
<p className="text-gray-500 text-xs mt-1">
|
||||
{achievement.earnedAt.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Friends Preview */}
|
||||
{driverProfile.socialSummary.friends.length > 0 && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-purple-400" />
|
||||
Friends
|
||||
<span className="text-sm text-gray-500 font-normal">({driverProfile.socialSummary.friends.length})</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{driverProfile.socialSummary.friends.slice(0, 8).map((friend) => (
|
||||
<Link
|
||||
key={friend.id}
|
||||
href={`/drivers/${friend.id}`}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline hover:border-purple-400/30 hover:bg-iron-gray transition-all"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full overflow-hidden bg-gradient-to-br from-primary-blue to-purple-600">
|
||||
<Image
|
||||
src={friend.avatarUrl || mediaConfig.avatars.defaultFallback}
|
||||
alt={friend.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-white">{friend.name}</span>
|
||||
<span className="text-lg">{getCountryFlag(friend.country)}</span>
|
||||
</Link>
|
||||
))}
|
||||
{driverProfile.socialSummary.friends.length > 8 && (
|
||||
<div className="flex items-center px-3 py-2 text-sm text-gray-400">+{driverProfile.socialSummary.friends.length - 8} more</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'stats' && stats && (
|
||||
<div className="space-y-6">
|
||||
{/* Detailed Performance Metrics */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-primary-blue" />
|
||||
Detailed Performance Metrics
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Performance Bars */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-4">Results Breakdown</h3>
|
||||
<HorizontalBarChart
|
||||
data={[
|
||||
{ label: 'Wins', value: stats.wins, color: 'bg-performance-green' },
|
||||
{ label: 'Podiums (2nd-3rd)', value: stats.podiums - stats.wins, color: 'bg-warning-amber' },
|
||||
{ label: 'DNFs', value: stats.dnfs, color: 'bg-red-500' },
|
||||
]}
|
||||
maxValue={stats.totalRaces}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Percent className="w-4 h-4 text-performance-green" />
|
||||
<span className="text-xs text-gray-500 uppercase">Win Rate</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-performance-green">
|
||||
{((stats.wins / stats.totalRaces) * 100).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Percent className="w-4 h-4 text-warning-amber" />
|
||||
<span className="text-xs text-gray-500 uppercase">Podium Rate</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-warning-amber">
|
||||
{((stats.podiums / stats.totalRaces) * 100).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Activity className="w-4 h-4 text-primary-blue" />
|
||||
<span className="text-xs text-gray-500 uppercase">Consistency</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-primary-blue">{stats.consistency}%</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Zap className="w-4 h-4 text-neon-aqua" />
|
||||
<span className="text-xs text-gray-500 uppercase">Finish Rate</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-neon-aqua">
|
||||
{(((stats.totalRaces - stats.dnfs) / stats.totalRaces) * 100).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Position Statistics */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<Flag className="w-5 h-5 text-red-400" />
|
||||
Position Statistics
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="p-4 rounded-xl bg-gradient-to-br from-performance-green/20 to-performance-green/5 border border-performance-green/30 text-center">
|
||||
<div className="text-4xl font-bold text-performance-green mb-1">P{stats.bestFinish}</div>
|
||||
<div className="text-xs text-gray-400 uppercase">Best Finish</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
|
||||
<div className="text-4xl font-bold text-primary-blue mb-1">
|
||||
P{(stats.avgFinish ?? 0).toFixed(1)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 uppercase">Avg Finish</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-gradient-to-br from-warning-amber/20 to-warning-amber/5 border border-warning-amber/30 text-center">
|
||||
<div className="text-4xl font-bold text-warning-amber mb-1">P{stats.worstFinish}</div>
|
||||
<div className="text-xs text-gray-400 uppercase">Worst Finish</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-gradient-to-br from-red-500/20 to-red-500/5 border border-red-500/30 text-center">
|
||||
<div className="text-4xl font-bold text-red-400 mb-1">{stats.dnfs}</div>
|
||||
<div className="text-xs text-gray-400 uppercase">DNFs</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Global Rankings */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<Trophy className="w-5 h-5 text-yellow-400" />
|
||||
Global Rankings
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="p-6 rounded-xl bg-gradient-to-br from-yellow-400/20 to-yellow-600/5 border border-yellow-400/30 text-center">
|
||||
<Trophy className="w-8 h-8 text-yellow-400 mx-auto mb-3" />
|
||||
<div className="text-3xl font-bold text-yellow-400 mb-1">#{globalRank}</div>
|
||||
<div className="text-sm text-gray-400">Global Rank</div>
|
||||
</div>
|
||||
<div className="p-6 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
|
||||
<Star className="w-8 h-8 text-primary-blue mx-auto mb-3" />
|
||||
<div className="text-3xl font-bold text-primary-blue mb-1">{stats.rating}</div>
|
||||
<div className="text-sm text-gray-400">Rating</div>
|
||||
</div>
|
||||
<div className="p-6 rounded-xl bg-gradient-to-br from-purple-400/20 to-purple-600/5 border border-purple-400/30 text-center">
|
||||
<TrendingUp className="w-8 h-8 text-purple-400 mx-auto mb-3" />
|
||||
<div className="text-3xl font-bold text-purple-400 mb-1">Top {stats.percentile}%</div>
|
||||
<div className="text-sm text-gray-400">Percentile</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'stats' && !stats && (
|
||||
<Card className="text-center py-12">
|
||||
<BarChart3 className="w-16 h-16 text-gray-600 mx-auto mb-4" />
|
||||
<p className="text-gray-400 mb-2">No statistics available yet</p>
|
||||
<p className="text-sm text-gray-500">This driver hasn't completed any races yet</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
{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>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Trophy, ArrowLeft, Medal } from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import Image from 'next/image';
|
||||
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 { Container } from '@/ui/Container';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import type { DriverRankingsViewData } from '@/lib/view-data/DriverRankingsViewData';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
import { RankingsPodium } from '@/components/drivers/RankingsPodium';
|
||||
import { RankingsTable } from '@/components/drivers/RankingsTable';
|
||||
|
||||
interface DriverRankingsTemplateProps {
|
||||
viewData: DriverRankingsViewData;
|
||||
@@ -17,209 +20,62 @@ interface DriverRankingsTemplateProps {
|
||||
onBackToLeaderboards?: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN TEMPLATE COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function DriverRankingsTemplate({
|
||||
viewData,
|
||||
onDriverClick,
|
||||
onBackToLeaderboards,
|
||||
}: DriverRankingsTemplateProps): React.ReactElement {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 pb-12">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
{onBackToLeaderboards && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBackToLeaderboards}
|
||||
className="flex items-center gap-2 mb-6"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Leaderboards
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/20">
|
||||
<Trophy className="w-7 h-7 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<Heading level={1} className="text-3xl lg:text-4xl">
|
||||
Driver Leaderboard
|
||||
</Heading>
|
||||
<p className="text-gray-400">Full rankings of all drivers by performance metrics</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top 3 Podium */}
|
||||
{viewData.podium.length > 0 && (
|
||||
<div className="mb-10">
|
||||
<div className="flex items-end justify-center gap-4 lg:gap-8">
|
||||
{[1, 0, 2].map((index) => {
|
||||
const driver = viewData.podium[index];
|
||||
if (!driver) return null;
|
||||
|
||||
const position = index === 1 ? 1 : index === 0 ? 2 : 3;
|
||||
const config = {
|
||||
1: { height: 'h-40', color: 'from-yellow-400/20 to-amber-500/10 border-yellow-400/40', crown: 'text-yellow-400', text: 'text-xl text-yellow-400' },
|
||||
2: { height: 'h-32', color: 'from-gray-400/20 to-gray-500/10 border-gray-400/40', crown: 'text-gray-300', text: 'text-lg text-gray-300' },
|
||||
3: { height: 'h-24', color: 'from-amber-600/20 to-amber-700/10 border-amber-600/40', crown: 'text-amber-600', text: 'text-base text-amber-600' },
|
||||
}[position];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={driver.id}
|
||||
type="button"
|
||||
onClick={() => onDriverClick?.(driver.id)}
|
||||
className="flex flex-col items-center group"
|
||||
>
|
||||
<div className="relative mb-4">
|
||||
<div className={`relative ${position === 1 ? 'w-24 h-24 lg:w-28 lg:h-28' : 'w-20 h-20 lg:w-24 lg:h-24'} rounded-full overflow-hidden border-4 ${position === 1 ? 'border-yellow-400 shadow-[0_0_30px_rgba(250,204,21,0.3)]' : position === 2 ? 'border-gray-300' : 'border-amber-600'} group-hover:scale-105 transition-transform`}>
|
||||
<Image
|
||||
src={driver.avatarUrl}
|
||||
alt={driver.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className={`absolute -bottom-2 left-1/2 -translate-x-1/2 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold bg-gradient-to-br ${config.color} border-2 ${config.crown}`}>
|
||||
{position}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className={`text-white font-semibold ${position === 1 ? 'text-lg' : 'text-base'} group-hover:text-primary-blue transition-colors mb-1`}>
|
||||
{driver.name}
|
||||
</p>
|
||||
|
||||
<p className={`font-mono font-bold ${position === 1 ? 'text-xl text-yellow-400' : 'text-lg text-primary-blue'}`}>
|
||||
{driver.rating.toString()}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 mt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 text-performance-green">🏆</span>
|
||||
{driver.wins}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 text-warning-amber">🏅</span>
|
||||
{driver.podiums}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={`mt-4 w-28 lg:w-36 ${config.height} rounded-t-lg bg-gradient-to-t ${config.color} border-t border-x flex items-end justify-center pb-4`}>
|
||||
<span className={`text-4xl lg:text-5xl font-black ${config.crown}`}>
|
||||
{position}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Leaderboard Table */}
|
||||
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
|
||||
{/* Table Header */}
|
||||
<div className="grid grid-cols-12 gap-4 px-4 py-3 bg-iron-gray/50 border-b border-charcoal-outline text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<div className="col-span-1 text-center">Rank</div>
|
||||
<div className="col-span-5 lg:col-span-4">Driver</div>
|
||||
<div className="col-span-2 text-center hidden md:block">Races</div>
|
||||
<div className="col-span-2 lg:col-span-1 text-center">Rating</div>
|
||||
<div className="col-span-2 lg:col-span-1 text-center">Wins</div>
|
||||
<div className="col-span-1 text-center hidden lg:block">Podiums</div>
|
||||
<div className="col-span-2 text-center">Win Rate</div>
|
||||
</div>
|
||||
|
||||
{/* Table Body */}
|
||||
<div className="divide-y divide-charcoal-outline/50">
|
||||
{viewData.drivers.map((driver) => {
|
||||
const position = driver.rank;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={driver.id}
|
||||
type="button"
|
||||
onClick={() => onDriverClick?.(driver.id)}
|
||||
className="grid grid-cols-12 gap-4 px-4 py-4 w-full text-left hover:bg-iron-gray/30 transition-colors group"
|
||||
<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} />}
|
||||
>
|
||||
{/* Position */}
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-bold border ${driver.medalBg} ${driver.medalColor}`}>
|
||||
{position <= 3 ? <Medal className="w-4 h-4" /> : position}
|
||||
</div>
|
||||
</div>
|
||||
Back to Leaderboards
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Driver Info */}
|
||||
<div className="col-span-5 lg:col-span-4 flex items-center gap-3">
|
||||
<div className="relative w-10 h-10 rounded-full overflow-hidden border-2 border-charcoal-outline">
|
||||
<Image src={driver.avatarUrl} alt={driver.name} fill className="object-cover" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-white font-semibold truncate group-hover:text-primary-blue transition-colors">
|
||||
{driver.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
{driver.nationality}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{driver.skillLevel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Surface variant="muted" rounded="xl" padding={3} style={{ background: 'linear-gradient(to bottom right, rgba(59, 130, 246, 0.2), rgba(59, 130, 246, 0.05))', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
||||
<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>
|
||||
|
||||
{/* Races */}
|
||||
<div className="col-span-2 items-center justify-center hidden md:flex">
|
||||
<span className="text-gray-400">{driver.racesCompleted}</span>
|
||||
</div>
|
||||
|
||||
{/* Rating */}
|
||||
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
|
||||
<span className="font-mono font-semibold text-white">
|
||||
{driver.rating.toString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Wins */}
|
||||
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
|
||||
<span className="font-mono font-semibold text-performance-green">
|
||||
{driver.wins}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Podiums */}
|
||||
<div className="col-span-1 items-center justify-center hidden lg:flex">
|
||||
<span className="font-mono font-semibold text-warning-amber">
|
||||
{driver.podiums}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Win Rate */}
|
||||
<div className="col-span-2 flex items-center justify-center">
|
||||
<span className="font-mono font-semibold text-white">
|
||||
{driver.winRate}%
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{viewData.drivers.length === 0 && (
|
||||
<div className="py-16 text-center">
|
||||
<span className="text-5xl mb-4 block">🔍</span>
|
||||
<p className="text-gray-400 mb-2">No drivers found</p>
|
||||
<p className="text-sm text-gray-500">There are no drivers in the system yet</p>
|
||||
</div>
|
||||
{/* 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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leaderboard Table */}
|
||||
<RankingsTable
|
||||
drivers={viewData.drivers.map(d => ({
|
||||
...d,
|
||||
rating: Number(d.rating),
|
||||
wins: Number(d.wins)
|
||||
}))}
|
||||
onDriverClick={onDriverClick}
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,189 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react';
|
||||
import {
|
||||
Trophy,
|
||||
Users,
|
||||
Search,
|
||||
Crown,
|
||||
} from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
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 '@/components/drivers/SkillDistribution';
|
||||
import { CategoryDistribution } from '@/components/drivers/CategoryDistribution';
|
||||
import { LeaderboardPreview } from '@/components/drivers/LeaderboardPreview';
|
||||
import { RecentActivity } from '@/components/drivers/RecentActivity';
|
||||
import { useDriverSearch } from '@/lib/hooks/useDriverSearch';
|
||||
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
|
||||
import { DriversHero } from '@/components/drivers/DriversHero';
|
||||
import { DriversSearch } from '@/components/drivers/DriversSearch';
|
||||
import { EmptyState } from '@/components/shared/state/EmptyState';
|
||||
import type { DriversViewData } from '@/lib/types/view-data/DriversViewData';
|
||||
|
||||
interface DriversTemplateProps {
|
||||
data: DriverLeaderboardViewModel | null;
|
||||
viewData: DriversViewData | null;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
filteredDrivers: DriversViewData['drivers'];
|
||||
onDriverClick: (id: string) => void;
|
||||
onViewLeaderboard: () => void;
|
||||
}
|
||||
|
||||
export function DriversTemplate({ data }: DriversTemplateProps) {
|
||||
const drivers = data?.drivers || [];
|
||||
const totalRaces = data?.totalRaces || 0;
|
||||
const totalWins = data?.totalWins || 0;
|
||||
const activeCount = data?.activeCount || 0;
|
||||
const isLoading = false;
|
||||
|
||||
const router = useRouter();
|
||||
const { searchQuery, setSearchQuery, filteredDrivers } = useDriverSearch(drivers);
|
||||
|
||||
const handleDriverClick = (driverId: string) => {
|
||||
router.push(`/drivers/${driverId}`);
|
||||
};
|
||||
export function DriversTemplate({
|
||||
viewData,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
filteredDrivers,
|
||||
onDriverClick,
|
||||
onViewLeaderboard
|
||||
}: DriversTemplateProps) {
|
||||
const drivers = viewData?.drivers || [];
|
||||
const totalRaces = viewData?.totalRaces || 0;
|
||||
const totalWins = viewData?.totalWins || 0;
|
||||
const activeCount = viewData?.activeCount || 0;
|
||||
|
||||
// Featured drivers (top 4)
|
||||
const featuredDrivers = filteredDrivers.slice(0, 4);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-gray-400">Loading drivers...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 pb-12">
|
||||
{/* Hero Section */}
|
||||
<div className="relative mb-10 py-10 px-8 rounded-2xl bg-gradient-to-br from-primary-blue/20 via-iron-gray/80 to-deep-graphite border border-primary-blue/30 overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-primary-blue/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 left-0 w-64 h-64 bg-yellow-400/5 rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 right-1/4 w-48 h-48 bg-performance-green/5 rounded-full blur-2xl" />
|
||||
<Container size="lg" py={8}>
|
||||
<Stack gap={10}>
|
||||
{/* Hero Section */}
|
||||
<DriversHero
|
||||
driverCount={drivers.length}
|
||||
activeCount={activeCount}
|
||||
totalWins={totalWins}
|
||||
totalRaces={totalRaces}
|
||||
onViewLeaderboard={onViewLeaderboard}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8">
|
||||
<div className="max-w-2xl">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/20">
|
||||
<Users className="w-6 h-6 text-primary-blue" />
|
||||
</div>
|
||||
<Heading level={1} className="text-3xl lg:text-4xl">
|
||||
Drivers
|
||||
</Heading>
|
||||
</div>
|
||||
<p className="text-gray-400 text-lg leading-relaxed mb-6">
|
||||
Meet the racers who make every lap count. From rookies to champions, track their journey and see who's dominating the grid.
|
||||
</p>
|
||||
{/* Search */}
|
||||
<DriversSearch query={searchQuery} onChange={onSearchChange} />
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-primary-blue" />
|
||||
<span className="text-sm text-gray-400">
|
||||
<span className="text-white font-semibold">{drivers.length}</span> drivers
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-performance-green animate-pulse" />
|
||||
<span className="text-sm text-gray-400">
|
||||
<span className="text-white font-semibold">{activeCount}</span> active
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-400" />
|
||||
<span className="text-sm text-gray-400">
|
||||
<span className="text-white font-semibold">{totalWins.toLocaleString()}</span> total wins
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-neon-aqua" />
|
||||
<span className="text-sm text-gray-400">
|
||||
<span className="text-white font-semibold">{totalRaces.toLocaleString()}</span> races
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => router.push('/leaderboards/drivers')}
|
||||
className="flex items-center gap-2 px-6 py-3"
|
||||
>
|
||||
<Trophy className="w-5 h-5" />
|
||||
View Leaderboard
|
||||
</Button>
|
||||
<p className="text-xs text-gray-500 text-center">See full driver rankings</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-8">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search drivers by name or nationality..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-11"
|
||||
{/* 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} />
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredDrivers.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title="No drivers found"
|
||||
description={`No drivers found matching "${searchQuery}"`}
|
||||
action={{
|
||||
label: 'Clear search',
|
||||
onClick: () => onSearchChange(''),
|
||||
variant: 'secondary'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Featured Drivers */}
|
||||
{!searchQuery && (
|
||||
<div className="mb-10">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-yellow-400/10 border border-yellow-400/20">
|
||||
<Crown className="w-5 h-5 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Featured Drivers</h2>
|
||||
<p className="text-xs text-gray-500">Top performers on the grid</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{featuredDrivers.map((driver, index) => (
|
||||
<FeaturedDriverCard
|
||||
key={driver.id}
|
||||
driver={driver}
|
||||
position={index + 1}
|
||||
onClick={() => handleDriverClick(driver.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Drivers */}
|
||||
{!searchQuery && <RecentActivity drivers={drivers} onDriverClick={handleDriverClick} />}
|
||||
|
||||
{/* Skill Distribution */}
|
||||
{!searchQuery && <SkillDistribution drivers={drivers} />}
|
||||
|
||||
{/* Category Distribution */}
|
||||
{!searchQuery && <CategoryDistribution drivers={drivers} />}
|
||||
|
||||
{/* Leaderboard Preview */}
|
||||
<LeaderboardPreview drivers={filteredDrivers} onDriverClick={handleDriverClick} />
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredDrivers.length === 0 && (
|
||||
<Card className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Search className="w-10 h-10 text-gray-600" />
|
||||
<p className="text-gray-400">No drivers found matching "{searchQuery}"</p>
|
||||
<Button variant="secondary" onClick={() => setSearchQuery('')}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import Image from 'next/image';
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Hero from '@/components/landing/Hero';
|
||||
import AlternatingSection from '@/components/landing/AlternatingSection';
|
||||
import FeatureGrid from '@/components/landing/FeatureGrid';
|
||||
@@ -9,25 +11,50 @@ import CareerProgressionMockup from '@/components/mockups/CareerProgressionMocku
|
||||
import RaceHistoryMockup from '@/components/mockups/RaceHistoryMockup';
|
||||
import CompanionAutomationMockup from '@/components/mockups/CompanionAutomationMockup';
|
||||
import SimPlatformMockup from '@/components/mockups/SimPlatformMockup';
|
||||
import MockupStack from '@/components/ui/MockupStack';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import MockupStack from '@/ui/MockupStack';
|
||||
import { Card } from '@/ui/Card';
|
||||
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 { Image } from '@/ui/Image';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { FeatureItem, ResultItem, StepItem } from '@/components/landing/LandingItems';
|
||||
|
||||
export interface HomeTemplateData {
|
||||
export interface HomeViewData {
|
||||
isAlpha: boolean;
|
||||
upcomingRaces: any[];
|
||||
topLeagues: any[];
|
||||
teams: any[];
|
||||
upcomingRaces: Array<{
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
formattedDate: string;
|
||||
}>;
|
||||
topLeagues: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}>;
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
logoUrl?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface HomeTemplateProps {
|
||||
data: HomeTemplateData;
|
||||
interface HomeTemplateProps {
|
||||
viewData: HomeViewData;
|
||||
}
|
||||
|
||||
export default function HomeTemplate({ data }: HomeTemplateProps) {
|
||||
export function HomeTemplate({ viewData }: HomeTemplateProps) {
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<Box as="main">
|
||||
<Hero />
|
||||
|
||||
{/* Section 1: A Persistent Identity */}
|
||||
@@ -35,55 +62,19 @@ export default function HomeTemplate({ data }: HomeTemplateProps) {
|
||||
heading="A Persistent Identity"
|
||||
backgroundVideo="/gameplay.mp4"
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
<Stack gap={4}>
|
||||
<Text>
|
||||
Your races, your seasons, your progress — finally in one place.
|
||||
</p>
|
||||
<div className="space-y-3 mt-4">
|
||||
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)]">
|
||||
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
|
||||
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-slate-200 leading-relaxed font-light">
|
||||
Lifetime stats and season history across all your leagues
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)]">
|
||||
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
|
||||
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-slate-200 leading-relaxed font-light">
|
||||
Track your performance, consistency, and team contributions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)]">
|
||||
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
|
||||
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-slate-200 leading-relaxed font-light">
|
||||
Your own rating that reflects real league competition
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4">
|
||||
</Text>
|
||||
<Stack 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" />
|
||||
</Stack>
|
||||
<Text>
|
||||
iRacing gives you physics. GridPilot gives you a career.
|
||||
</p>
|
||||
</>
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
mockup={<CareerProgressionMockup />}
|
||||
layout="text-left"
|
||||
@@ -96,55 +87,19 @@ export default function HomeTemplate({ data }: HomeTemplateProps) {
|
||||
heading="Results That Actually Stay"
|
||||
backgroundImage="/images/ff1600.jpeg"
|
||||
description={
|
||||
<>
|
||||
<p className="text-sm md:text-base leading-relaxed">
|
||||
<Stack gap={4}>
|
||||
<Text size="sm">
|
||||
Every race you run stays with you.
|
||||
</p>
|
||||
<div className="space-y-3 mt-4 md:mt-6">
|
||||
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
|
||||
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="flex items-start gap-2.5 md:gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
|
||||
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
|
||||
Your stats, your team, your story — all connected
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
|
||||
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="flex items-start gap-2.5 md:gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
|
||||
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
|
||||
One race result updates your profile, team points, rating, and season history
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
|
||||
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="flex items-start gap-2.5 md:gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
|
||||
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
|
||||
No more fragmented data across spreadsheets and forums
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
|
||||
</Text>
|
||||
<Stack gap={3}>
|
||||
<ResultItem text="Your stats, your team, your story — all connected" color="#ef4444" />
|
||||
<ResultItem text="One race result updates your profile, team points, rating, and season history" color="#ef4444" />
|
||||
<ResultItem text="No more fragmented data across spreadsheets and forums" color="#ef4444" />
|
||||
</Stack>
|
||||
<Text size="sm">
|
||||
Your racing career, finally in one place.
|
||||
</p>
|
||||
</>
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
mockup={<MockupStack index={1}><RaceHistoryMockup /></MockupStack>}
|
||||
layout="text-right"
|
||||
@@ -154,49 +109,19 @@ export default function HomeTemplate({ data }: HomeTemplateProps) {
|
||||
<AlternatingSection
|
||||
heading="Automatic Session Creation"
|
||||
description={
|
||||
<>
|
||||
<p className="text-sm md:text-base leading-relaxed">
|
||||
<Stack gap={4}>
|
||||
<Text size="sm">
|
||||
Setting up league races used to mean clicking through iRacing's wizard 20 times.
|
||||
</p>
|
||||
<div className="space-y-3 mt-4 md:mt-6">
|
||||
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
|
||||
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="flex items-start gap-2.5 md:gap-3 relative">
|
||||
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
|
||||
<span className="text-primary-blue font-bold text-sm">1</span>
|
||||
</div>
|
||||
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
|
||||
Our companion app syncs with your league schedule
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
|
||||
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="flex items-start gap-2.5 md:gap-3 relative">
|
||||
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
|
||||
<span className="text-primary-blue font-bold text-sm">2</span>
|
||||
</div>
|
||||
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
|
||||
When it's race time, it creates the iRacing session automatically
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
|
||||
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="flex items-start gap-2.5 md:gap-3 relative">
|
||||
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
|
||||
<span className="text-primary-blue font-bold text-sm">3</span>
|
||||
</div>
|
||||
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
|
||||
No clicking through wizards. No manual setup
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
|
||||
</Text>
|
||||
<Stack 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" />
|
||||
</Stack>
|
||||
<Text size="sm">
|
||||
Automation instead of repetition.
|
||||
</p>
|
||||
</>
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
mockup={<CompanionAutomationMockup />}
|
||||
layout="text-left"
|
||||
@@ -207,149 +132,145 @@ export default function HomeTemplate({ data }: HomeTemplateProps) {
|
||||
heading="Built for iRacing. Ready for the future."
|
||||
backgroundImage="/images/lmp3.jpeg"
|
||||
description={
|
||||
<>
|
||||
<p className="text-sm md:text-base leading-relaxed">
|
||||
<Stack gap={4}>
|
||||
<Text size="sm">
|
||||
Right now, we're focused on making iRacing league racing better.
|
||||
</p>
|
||||
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
But sims come and go. Your leagues, your teams, your rating — those stay.
|
||||
</p>
|
||||
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
GridPilot is built to outlast any single platform.
|
||||
</p>
|
||||
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
When the next sim arrives, your competitive identity moves with you.
|
||||
</p>
|
||||
</>
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
mockup={<SimPlatformMockup />}
|
||||
layout="text-right"
|
||||
/>
|
||||
|
||||
{/* Alpha-only discovery section */}
|
||||
{data.isAlpha && (
|
||||
<section className="max-w-7xl mx-auto mt-20 mb-20 px-6">
|
||||
<div className="flex items-baseline justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-white">Discover the grid</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
{viewData.isAlpha && (
|
||||
<Container size="lg" py={12}>
|
||||
<Stack gap={8}>
|
||||
<Box>
|
||||
<Heading level={2}>Discover the grid</Heading>
|
||||
<Text size="sm" color="text-gray-400" block mt={2}>
|
||||
Explore leagues, teams, and races that make up the GridPilot ecosystem.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-3">
|
||||
{/* Top leagues */}
|
||||
<Card className="bg-iron-gray/80">
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-white">Featured leagues</h3>
|
||||
<Button
|
||||
as="a"
|
||||
href="/leagues"
|
||||
variant="secondary"
|
||||
className="text-[11px] px-3 py-1.5"
|
||||
>
|
||||
View all
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{data.topLeagues.slice(0, 4).map((league: any) => (
|
||||
<li key={league.id} className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-md bg-primary-blue/15 border border-primary-blue/30 flex items-center justify-center text-xs font-semibold text-primary-blue">
|
||||
{league.name
|
||||
.split(' ')
|
||||
.map((word: string) => word[0])
|
||||
.join('')
|
||||
.slice(0, 3)
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white truncate">{league.name}</p>
|
||||
<p className="text-xs text-gray-400 line-clamp-2">
|
||||
{league.description}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
<Grid cols={3} gap={8}>
|
||||
{/* Top leagues */}
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading level={3} style={{ fontSize: '0.875rem' }}>Featured leagues</Heading>
|
||||
<Link href={routes.public.leagues}>
|
||||
<Button variant="secondary" size="sm">
|
||||
View all
|
||||
</Button>
|
||||
</Link>
|
||||
</Stack>
|
||||
<Stack gap={3}>
|
||||
{viewData.topLeagues.slice(0, 4).map((league) => (
|
||||
<Box key={league.id}>
|
||||
<Stack direction="row" align="start" gap={3}>
|
||||
<Surface variant="muted" rounded="md" border padding={1} style={{ width: '2.5rem', height: '2.5rem', backgroundColor: 'rgba(59, 130, 246, 0.1)', borderColor: 'rgba(59, 130, 246, 0.3)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text size="xs" weight="bold" color="text-primary-blue">
|
||||
{league.name.split(' ').map((word) => word[0]).join('').slice(0, 3).toUpperCase()}
|
||||
</Text>
|
||||
</Surface>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text color="text-white" block truncate>{league.name}</Text>
|
||||
<Text size="xs" color="text-gray-400" block mt={1} truncate>{league.description}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Teams */}
|
||||
<Card className="bg-iron-gray/80">
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-white">Teams on the grid</h3>
|
||||
<Button
|
||||
as="a"
|
||||
href="/teams"
|
||||
variant="secondary"
|
||||
className="text-[11px] px-3 py-1.5"
|
||||
>
|
||||
Browse teams
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{data.teams.slice(0, 4).map(team => (
|
||||
<li key={team.id} className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-md bg-charcoal-outline flex items-center justify-center overflow-hidden border border-charcoal-outline">
|
||||
<Image
|
||||
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
|
||||
alt={team.name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white truncate">{team.name}</p>
|
||||
<p className="text-xs text-gray-400 line-clamp-2">
|
||||
{team.description}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
{/* Teams */}
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading level={3} style={{ fontSize: '0.875rem' }}>Teams on the grid</Heading>
|
||||
<Link href={routes.public.teams}>
|
||||
<Button variant="secondary" size="sm">
|
||||
Browse teams
|
||||
</Button>
|
||||
</Link>
|
||||
</Stack>
|
||||
<Stack gap={3}>
|
||||
{viewData.teams.slice(0, 4).map(team => (
|
||||
<Box key={team.id}>
|
||||
<Stack direction="row" align="start" gap={3}>
|
||||
<Surface variant="muted" rounded="md" border padding={1} style={{ width: '2.5rem', height: '2.5rem', overflow: 'hidden', backgroundColor: '#262626' }}>
|
||||
<Image
|
||||
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
|
||||
alt={team.name}
|
||||
width={40}
|
||||
height={40}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</Surface>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text color="text-white" block truncate>{team.name}</Text>
|
||||
<Text size="xs" color="text-gray-400" block mt={1} truncate>{team.description}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Upcoming races */}
|
||||
<Card className="bg-iron-gray/80">
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-white">Upcoming races</h3>
|
||||
<Button
|
||||
as="a"
|
||||
href="/races"
|
||||
variant="secondary"
|
||||
className="text-[11px] px-3 py-1.5"
|
||||
>
|
||||
View schedule
|
||||
</Button>
|
||||
</div>
|
||||
{data.upcomingRaces.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">
|
||||
No races scheduled in this demo snapshot.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3 text-sm">
|
||||
{data.upcomingRaces.map(race => (
|
||||
<li key={race.id} className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white truncate">{race.track}</p>
|
||||
<p className="text-xs text-gray-400 truncate">{race.car}</p>
|
||||
</div>
|
||||
<div className="text-right text-xs text-gray-500 whitespace-nowrap">
|
||||
{race.formattedDate}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
{/* Upcoming races */}
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading level={3} style={{ fontSize: '0.875rem' }}>Upcoming races</Heading>
|
||||
<Link href={routes.public.races}>
|
||||
<Button variant="secondary" size="sm">
|
||||
View schedule
|
||||
</Button>
|
||||
</Link>
|
||||
</Stack>
|
||||
{viewData.upcomingRaces.length === 0 ? (
|
||||
<Text size="xs" color="text-gray-400">
|
||||
No races scheduled in this demo snapshot.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={3}>
|
||||
{viewData.upcomingRaces.map(race => (
|
||||
<Box key={race.id}>
|
||||
<Stack direction="row" align="start" justify="between" gap={3}>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text color="text-white" block truncate>{race.track}</Text>
|
||||
<Text size="xs" color="text-gray-400" block mt={1} truncate>{race.car}</Text>
|
||||
</Box>
|
||||
<Text size="xs" color="text-gray-500" style={{ whiteSpace: 'nowrap' }}>
|
||||
{race.formattedDate}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Container>
|
||||
)}
|
||||
|
||||
<DiscordCTA />
|
||||
<FAQ />
|
||||
<Footer />
|
||||
</main>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Trophy, Users, Award } from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { GridItem } from '@/ui/GridItem';
|
||||
import { DriverLeaderboardPreview } from '@/components/leaderboards/DriverLeaderboardPreview';
|
||||
import { TeamLeaderboardPreview } from '@/components/leaderboards/TeamLeaderboardPreview';
|
||||
import { LeaderboardsHero } from '@/components/leaderboards/LeaderboardsHero';
|
||||
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
interface LeaderboardsTemplateProps {
|
||||
viewData: LeaderboardsViewData;
|
||||
onDriverClick: (id: string) => void;
|
||||
onTeamClick: (id: string) => void;
|
||||
onNavigateToDrivers: () => void;
|
||||
onNavigateToTeams: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN TEMPLATE COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function LeaderboardsTemplate({ viewData }: LeaderboardsTemplateProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleDriverClick = (driverId: string) => {
|
||||
router.push(routes.driver.detail(driverId));
|
||||
};
|
||||
|
||||
const handleTeamClick = (teamId: string) => {
|
||||
router.push(routes.team.detail(teamId));
|
||||
};
|
||||
|
||||
const handleNavigateToDrivers = () => {
|
||||
router.push(routes.leaderboards.drivers);
|
||||
};
|
||||
|
||||
const handleNavigateToTeams = () => {
|
||||
router.push(routes.team.leaderboard);
|
||||
};
|
||||
|
||||
export function LeaderboardsTemplate({
|
||||
viewData,
|
||||
onDriverClick,
|
||||
onTeamClick,
|
||||
onNavigateToDrivers,
|
||||
onNavigateToTeams
|
||||
}: LeaderboardsTemplateProps) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 pb-12">
|
||||
<div className="relative mb-10 py-10 px-8 rounded-2xl bg-gradient-to-br from-yellow-600/20 via-iron-gray/80 to-deep-graphite border border-yellow-500/20 overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-yellow-400/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 left-0 w-64 h-64 bg-amber-600/5 rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 right-1/4 w-48 h-48 bg-purple-500/5 rounded-full blur-2xl" />
|
||||
<Container size="lg" py={8}>
|
||||
<Box mb={10}>
|
||||
<LeaderboardsHero
|
||||
onNavigateToDrivers={onNavigateToDrivers}
|
||||
onNavigateToTeams={onNavigateToTeams}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-yellow-400/20 to-amber-600/10 border border-yellow-400/30">
|
||||
<Award className="w-7 h-7 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<Heading level={1} className="text-3xl lg:text-4xl">
|
||||
Leaderboards
|
||||
</Heading>
|
||||
<p className="text-gray-400">Where champions rise and legends are made</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-400 text-lg leading-relaxed max-w-2xl mb-6">
|
||||
Track the best drivers and teams across all competitions. Every race counts. Every position matters. Who will claim the throne?
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleNavigateToDrivers}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Trophy className="w-4 h-4 text-primary-blue" />
|
||||
Driver Rankings
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleNavigateToTeams}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Users className="w-4 h-4 text-purple-400" />
|
||||
Team Rankings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<DriverLeaderboardPreview drivers={viewData.drivers} onDriverClick={handleDriverClick} onNavigateToDrivers={handleNavigateToDrivers} />
|
||||
<TeamLeaderboardPreview teams={viewData.teams} onTeamClick={handleTeamClick} onNavigateToTeams={handleNavigateToTeams} />
|
||||
</div>
|
||||
</div>
|
||||
<Grid cols={12} gap={6}>
|
||||
<GridItem colSpan={12} lgSpan={6}>
|
||||
<DriverLeaderboardPreview
|
||||
drivers={viewData.drivers}
|
||||
onDriverClick={onDriverClick}
|
||||
onNavigateToDrivers={onNavigateToDrivers}
|
||||
/>
|
||||
</GridItem>
|
||||
<GridItem colSpan={12} lgSpan={6}>
|
||||
<TeamLeaderboardPreview
|
||||
teams={viewData.teams}
|
||||
onTeamClick={onTeamClick}
|
||||
onNavigateToTeams={onNavigateToTeams}
|
||||
/>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { LeagueAdminScheduleViewModel } from '@/lib/view-models/LeagueAdminScheduleViewModel';
|
||||
import type { LeagueSeasonSummaryViewModel } from '@/lib/view-models/LeagueSeasonSummaryViewModel';
|
||||
import Card from '@/components/ui/Card';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
import React, { useMemo } 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 { Button } from '@/ui/Button';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { GridItem } from '@/ui/GridItem';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import type { LeagueAdminScheduleViewData } from '@/lib/view-data/LeagueAdminScheduleViewData';
|
||||
|
||||
interface LeagueAdminScheduleTemplateProps {
|
||||
data: {
|
||||
schedule: LeagueAdminScheduleViewModel;
|
||||
seasons: LeagueSeasonSummaryViewModel[];
|
||||
seasonId: string;
|
||||
};
|
||||
viewData: LeagueAdminScheduleViewData;
|
||||
onSeasonChange: (seasonId: string) => void;
|
||||
onPublishToggle: () => void;
|
||||
onAddOrSave: () => void;
|
||||
@@ -39,12 +40,8 @@ interface LeagueAdminScheduleTemplateProps {
|
||||
setScheduledAtIso: (value: string) => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN TEMPLATE COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function LeagueAdminScheduleTemplate({
|
||||
data,
|
||||
viewData,
|
||||
onSeasonChange,
|
||||
onPublishToggle,
|
||||
onAddOrSave,
|
||||
@@ -62,10 +59,10 @@ export function LeagueAdminScheduleTemplate({
|
||||
setCar,
|
||||
setScheduledAtIso,
|
||||
}: LeagueAdminScheduleTemplateProps) {
|
||||
const { schedule, seasons, seasonId } = data;
|
||||
const { races, seasons, seasonId, published } = viewData;
|
||||
|
||||
const isEditing = editingRaceId !== null;
|
||||
const publishedLabel = schedule.published ? 'Published' : 'Unpublished';
|
||||
const publishedLabel = published ? 'Published' : 'Unpublished';
|
||||
|
||||
const selectedSeasonLabel = useMemo(() => {
|
||||
const selected = seasons.find((s) => s.seasonId === seasonId);
|
||||
@@ -73,162 +70,142 @@ export function LeagueAdminScheduleTemplate({
|
||||
}, [seasons, seasonId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Stack gap={6}>
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Schedule Admin</h1>
|
||||
<p className="text-sm text-gray-400">Create, edit, and publish season races.</p>
|
||||
</div>
|
||||
<Stack gap={6}>
|
||||
<Box>
|
||||
<Heading level={1}>Schedule Admin</Heading>
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>Create, edit, and publish season races.</Text>
|
||||
</Box>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm text-gray-300" htmlFor="seasonId">
|
||||
Season
|
||||
</label>
|
||||
{seasons.length > 0 ? (
|
||||
<select
|
||||
id="seasonId"
|
||||
value={seasonId}
|
||||
onChange={(e) => onSeasonChange(e.target.value)}
|
||||
className="bg-iron-gray text-white px-3 py-2 rounded"
|
||||
>
|
||||
{seasons.map((s) => (
|
||||
<option key={s.seasonId} value={s.seasonId}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
id="seasonId"
|
||||
value={seasonId}
|
||||
onChange={(e) => onSeasonChange(e.target.value)}
|
||||
className="bg-iron-gray text-white px-3 py-2 rounded"
|
||||
placeholder="season-id"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">Selected: {selectedSeasonLabel}</p>
|
||||
</div>
|
||||
<Box>
|
||||
<Text size="sm" color="text-gray-300" block mb={2}>Season</Text>
|
||||
<Select
|
||||
value={seasonId}
|
||||
onChange={(e) => onSeasonChange(e.target.value)}
|
||||
options={seasons.map(s => ({ value: s.seasonId, label: s.name }))}
|
||||
/>
|
||||
<Text size="xs" color="text-gray-500" block mt={1}>Selected: {selectedSeasonLabel}</Text>
|
||||
</Box>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-gray-300">
|
||||
Status: <span className="font-medium text-white">{publishedLabel}</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Text size="sm" color="text-gray-300">
|
||||
Status: <Text weight="medium" color="text-white">{publishedLabel}</Text>
|
||||
</Text>
|
||||
<Button
|
||||
onClick={onPublishToggle}
|
||||
disabled={!schedule || isPublishing}
|
||||
className="px-3 py-1.5 rounded bg-primary-blue text-white disabled:opacity-50"
|
||||
disabled={isPublishing}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
{isPublishing ? 'Processing...' : (schedule?.published ? 'Unpublish' : 'Publish')}
|
||||
</button>
|
||||
</div>
|
||||
{isPublishing ? 'Processing...' : (published ? 'Unpublish' : 'Publish')}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<div className="border-t border-charcoal-outline pt-4 space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">{isEditing ? 'Edit race' : 'Add race'}</h2>
|
||||
<Box pt={6} style={{ borderTop: '1px solid #262626' }}>
|
||||
<Box mb={4}>
|
||||
<Heading level={2}>{isEditing ? 'Edit race' : 'Add race'}</Heading>
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="track" className="text-sm text-gray-300">
|
||||
Track
|
||||
</label>
|
||||
<input
|
||||
id="track"
|
||||
<Grid cols={3} gap={4}>
|
||||
<Box>
|
||||
<Text size="sm" color="text-gray-300" block mb={2}>Track</Text>
|
||||
<Input
|
||||
value={track}
|
||||
onChange={(e) => setTrack(e.target.value)}
|
||||
className="bg-iron-gray text-white px-3 py-2 rounded"
|
||||
placeholder="Track name"
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="car" className="text-sm text-gray-300">
|
||||
Car
|
||||
</label>
|
||||
<input
|
||||
id="car"
|
||||
<Box>
|
||||
<Text size="sm" color="text-gray-300" block mb={2}>Car</Text>
|
||||
<Input
|
||||
value={car}
|
||||
onChange={(e) => setCar(e.target.value)}
|
||||
className="bg-iron-gray text-white px-3 py-2 rounded"
|
||||
placeholder="Car name"
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="scheduledAtIso" className="text-sm text-gray-300">
|
||||
Scheduled At (ISO)
|
||||
</label>
|
||||
<input
|
||||
id="scheduledAtIso"
|
||||
<Box>
|
||||
<Text size="sm" color="text-gray-300" block mb={2}>Scheduled At (ISO)</Text>
|
||||
<Input
|
||||
value={scheduledAtIso}
|
||||
onChange={(e) => setScheduledAtIso(e.target.value)}
|
||||
className="bg-iron-gray text-white px-3 py-2 rounded"
|
||||
placeholder="2025-01-01T12:00:00.000Z"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
<Stack direction="row" gap={3} mt={6}>
|
||||
<Button
|
||||
onClick={onAddOrSave}
|
||||
disabled={isSaving}
|
||||
className="px-3 py-1.5 rounded bg-primary-blue text-white"
|
||||
variant="primary"
|
||||
>
|
||||
{isSaving ? 'Processing...' : (isEditing ? 'Save' : 'Add race')}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={onCancelEdit}
|
||||
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
|
||||
variant="secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<div className="border-t border-charcoal-outline pt-4 space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">Races</h2>
|
||||
<Box pt={6} style={{ borderTop: '1px solid #262626' }}>
|
||||
<Box mb={4}>
|
||||
<Heading level={2}>Races</Heading>
|
||||
</Box>
|
||||
|
||||
{schedule?.races.length ? (
|
||||
<div className="space-y-2">
|
||||
{schedule.races.map((race) => (
|
||||
<div
|
||||
{races.length > 0 ? (
|
||||
<Stack gap={3}>
|
||||
{races.map((race) => (
|
||||
<Surface
|
||||
key={race.id}
|
||||
className="flex items-center justify-between gap-3 bg-deep-graphite border border-charcoal-outline rounded p-3"
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
border
|
||||
padding={4}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-white font-medium truncate">{race.name}</p>
|
||||
<p className="text-xs text-gray-400 truncate">{race.scheduledAt.toISOString()}</p>
|
||||
</div>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Box>
|
||||
<Text weight="medium" color="text-white" block>{race.name}</Text>
|
||||
<Text size="xs" color="text-gray-400" block mt={1}>{race.scheduledAt}</Text>
|
||||
</Box>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(race.id)}
|
||||
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(race.id)}
|
||||
disabled={isDeleting === race.id}
|
||||
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
|
||||
>
|
||||
{isDeleting === race.id ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Stack direction="row" gap={2}>
|
||||
<Button
|
||||
onClick={() => onEdit(race.id)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onDelete(race.id)}
|
||||
disabled={isDeleting === race.id}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{isDeleting === race.id ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Surface>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
) : (
|
||||
<div className="py-4 text-sm text-gray-500">No races yet.</div>
|
||||
<Box py={4}>
|
||||
<Text size="sm" color="text-gray-500" block>No races yet.</Text>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Section } from '@/ui/Section';
|
||||
import { Layout } from '@/ui/Layout';
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
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 { Heading } from '@/ui/Heading';
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import { LeagueTabs } from '@/components/leagues/LeagueTabs';
|
||||
import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData';
|
||||
|
||||
interface Tab {
|
||||
label: string;
|
||||
@@ -11,58 +17,40 @@ interface Tab {
|
||||
}
|
||||
|
||||
interface LeagueDetailTemplateProps {
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
leagueDescription: string;
|
||||
viewData: LeagueDetailViewData;
|
||||
tabs: Tab[];
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LeagueDetailTemplate({
|
||||
leagueId,
|
||||
leagueName,
|
||||
leagueDescription,
|
||||
viewData,
|
||||
tabs,
|
||||
children,
|
||||
}: LeagueDetailTemplateProps) {
|
||||
return (
|
||||
<Layout>
|
||||
<Section>
|
||||
<Container size="lg" py={6}>
|
||||
<Stack gap={6}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Leagues', href: '/leagues' },
|
||||
{ label: leagueName },
|
||||
{ label: viewData.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<Text size="3xl" weight="bold" className="text-white">
|
||||
{leagueName}
|
||||
<Box>
|
||||
<Heading level={1}>{viewData.name}</Heading>
|
||||
<Text color="text-gray-400" block mt={2}>
|
||||
{viewData.description}
|
||||
</Text>
|
||||
<Text size="base" className="text-gray-400 mt-2">
|
||||
{leagueDescription}
|
||||
</Text>
|
||||
</Section>
|
||||
</Box>
|
||||
|
||||
<Section>
|
||||
<div className="flex gap-6 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className="pb-3 px-1 font-medium whitespace-nowrap transition-colors text-gray-400 hover:text-white"
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
<LeagueTabs tabs={tabs} />
|
||||
|
||||
<Section>
|
||||
<Box>
|
||||
{children}
|
||||
</Section>
|
||||
</Section>
|
||||
</Layout>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
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 '@/components/leagues/PointsTable';
|
||||
import type { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
type RulebookSection = 'scoring' | 'conduct' | 'protests' | 'penalties';
|
||||
import { RulebookTabs, type RulebookSection } from '@/components/leagues/RulebookTabs';
|
||||
import type { LeagueRulebookViewData } from '@/lib/view-data/LeagueRulebookViewData';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface LeagueRulebookTemplateProps {
|
||||
viewModel: LeagueDetailPageViewModel;
|
||||
viewData: LeagueRulebookViewData;
|
||||
activeSection: RulebookSection;
|
||||
onSectionChange: (section: RulebookSection) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN TEMPLATE COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function LeagueRulebookTemplate({
|
||||
viewModel,
|
||||
viewData,
|
||||
activeSection,
|
||||
onSectionChange,
|
||||
loading = false,
|
||||
}: LeagueRulebookTemplateProps) {
|
||||
const [activeSection, setActiveSection] = useState<RulebookSection>('scoring');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12 text-gray-400">Loading rulebook...</div>
|
||||
<Stack align="center" py={12}>
|
||||
<Text color="text-gray-400">Loading rulebook...</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!viewModel || !viewModel.scoringConfig) {
|
||||
if (!viewData || !viewData.scoringConfig) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12 text-gray-400">Unable to load rulebook</div>
|
||||
<Stack align="center" py={12}>
|
||||
<Text color="text-gray-400">Unable to load rulebook</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const primaryChampionship = viewModel.scoringConfig.championships.find(c => c.type === 'driver') ?? viewModel.scoringConfig.championships[0];
|
||||
const { scoringConfig } = viewData;
|
||||
const primaryChampionship = scoringConfig.championships.find(c => c.type === 'driver') ?? scoringConfig.championships[0];
|
||||
const positionPoints: { position: number; points: number }[] = primaryChampionship?.pointsPreview
|
||||
.filter((p): p is { sessionType: string; position: number; points: number } => p.sessionType === primaryChampionship.sessionTypes[0])
|
||||
.filter((p) => p.sessionType === primaryChampionship.sessionTypes[0])
|
||||
.map(p => ({ position: p.position, points: p.points }))
|
||||
.sort((a, b) => a.position - b.position) || [];
|
||||
|
||||
const sections: { id: RulebookSection; label: string }[] = [
|
||||
{ id: 'scoring', label: 'Scoring' },
|
||||
{ id: 'conduct', label: 'Conduct' },
|
||||
{ id: 'protests', label: 'Protests' },
|
||||
{ id: 'penalties', label: 'Penalties' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Stack gap={6}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Rulebook</h1>
|
||||
<p className="text-sm text-gray-400 mt-1">Official rules and regulations</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-blue/10 border border-primary-blue/20">
|
||||
<span className="text-sm font-medium text-primary-blue">{viewModel.scoringConfig.scoringPresetName || 'Custom Rules'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<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 */}
|
||||
<div className="flex gap-1 p-1 bg-deep-graphite rounded-lg border border-charcoal-outline">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeSection === section.id
|
||||
? 'bg-iron-gray text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-iron-gray/50'
|
||||
}`}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<RulebookTabs activeSection={activeSection} onSectionChange={onSectionChange} />
|
||||
|
||||
{/* Content Sections */}
|
||||
{activeSection === 'scoring' && (
|
||||
<div className="space-y-6">
|
||||
<Stack gap={6}>
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Platform</p>
|
||||
<p className="text-lg font-semibold text-white">{viewModel.scoringConfig.gameName}</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Championships</p>
|
||||
<p className="text-lg font-semibold text-white">{viewModel.scoringConfig.championships.length}</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Sessions Scored</p>
|
||||
<p className="text-lg font-semibold text-white capitalize">
|
||||
{primaryChampionship?.sessionTypes.join(', ') || 'Main'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Drop Policy</p>
|
||||
<p className="text-lg font-semibold text-white truncate" title={viewModel.scoringConfig.dropPolicySummary}>
|
||||
{viewModel.scoringConfig.dropPolicySummary.includes('All') ? 'None' : 'Active'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Points Table */}
|
||||
<PointsTable points={positionPoints} />
|
||||
@@ -118,134 +87,137 @@ export function LeagueRulebookTemplate({
|
||||
{/* Bonus Points */}
|
||||
{primaryChampionship?.bonusSummary && primaryChampionship.bonusSummary.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Bonus Points</h2>
|
||||
<div className="space-y-2">
|
||||
{primaryChampionship.bonusSummary.map((bonus, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-4 p-3 bg-deep-graphite rounded-lg border border-charcoal-outline"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-performance-green/10 border border-performance-green/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-performance-green text-sm font-bold">+</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">{bonus}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Stack gap={4}>
|
||||
<Heading level={2}>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} style={{ width: '2rem', height: '2rem', backgroundColor: 'rgba(16, 185, 129, 0.1)', border: '1px solid rgba(16, 185, 129, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text color="text-performance-green" weight="bold">+</Text>
|
||||
</Surface>
|
||||
<Text size="sm" color="text-gray-300">{bonus}</Text>
|
||||
</Stack>
|
||||
</Surface>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Drop Policy */}
|
||||
{!viewModel.scoringConfig.dropPolicySummary.includes('All results count') && (
|
||||
{!scoringConfig.dropPolicySummary.includes('All results count') && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Drop Policy</h2>
|
||||
<p className="text-sm text-gray-300">{viewModel.scoringConfig.dropPolicySummary}</p>
|
||||
<p className="text-xs text-gray-500 mt-3">
|
||||
Drop rules are applied automatically when calculating championship standings.
|
||||
</p>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{activeSection === 'conduct' && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Driver Conduct</h2>
|
||||
<div className="space-y-4 text-sm text-gray-300">
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">1. Respect</h3>
|
||||
<p>All drivers must treat each other with respect. Abusive language, harassment, or unsportsmanlike behavior will not be tolerated.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">2. Clean Racing</h3>
|
||||
<p>Intentional wrecking, blocking, or dangerous driving is prohibited. Leave space for other drivers and race cleanly.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">3. Track Limits</h3>
|
||||
<p>Drivers must stay within track limits. Gaining a lasting advantage by exceeding track limits may result in penalties.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">4. Blue Flags</h3>
|
||||
<p>Lapped cars must yield to faster traffic within a reasonable time. Failure to do so may result in penalties.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">5. Communication</h3>
|
||||
<p>Drivers are expected to communicate respectfully in voice and text chat during sessions.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Stack gap={4}>
|
||||
<Heading level={2}>Driver Conduct</Heading>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{activeSection === 'protests' && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Protest Process</h2>
|
||||
<div className="space-y-4 text-sm text-gray-300">
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">Filing a Protest</h3>
|
||||
<p>Protests can be filed within 48 hours of the race conclusion. Include the lap number, drivers involved, and a clear description of the incident.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">Evidence</h3>
|
||||
<p>Video evidence is highly recommended but not required. Stewards will review available replay data.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">Review Process</h3>
|
||||
<p>League stewards will review protests and make decisions within 72 hours. Decisions are final unless new evidence is presented.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">Outcomes</h3>
|
||||
<p>Protests may result in no action, warnings, time penalties, position penalties, or points deductions depending on severity.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Stack gap={4}>
|
||||
<Heading level={2}>Protest Process</Heading>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{activeSection === 'penalties' && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Penalty Guidelines</h2>
|
||||
<div className="space-y-4 text-sm text-gray-300">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-charcoal-outline">
|
||||
<th className="text-left py-2 font-medium text-gray-400">Infraction</th>
|
||||
<th className="text-left py-2 font-medium text-gray-400">Typical Penalty</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-charcoal-outline/50">
|
||||
<tr>
|
||||
<td className="py-3">Causing avoidable contact</td>
|
||||
<td className="py-3 text-warning-amber">5-10 second time penalty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Unsafe rejoin</td>
|
||||
<td className="py-3 text-warning-amber">5 second time penalty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Blocking</td>
|
||||
<td className="py-3 text-warning-amber">Warning or 3 second penalty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Repeated track limit violations</td>
|
||||
<td className="py-3 text-warning-amber">5 second penalty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Intentional wrecking</td>
|
||||
<td className="py-3 text-red-400">Disqualification</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Unsportsmanlike conduct</td>
|
||||
<td className="py-3 text-red-400">Points deduction or ban</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-4">
|
||||
Penalties are applied at steward discretion based on incident severity and driver history.
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function StatItem({ label, value }: { label: string, value: string | number }) {
|
||||
return (
|
||||
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: '#262626', borderColor: '#262626' }}>
|
||||
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block mb={1}>{label}</Text>
|
||||
<Text weight="semibold" color="text-white" style={{ fontSize: '1.125rem' }}>{value}</Text>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function PenaltyRow({ infraction, penalty, color }: { infraction: string, penalty: string, color?: string }) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Text size="sm" color="text-gray-300">{infraction}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text size="sm" style={{ color: color || '#f59e0b' }}>{penalty}</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { LeagueScheduleViewData } from '@/lib/view-data/leagues/LeagueScheduleViewData';
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Section } from '@/ui/Section';
|
||||
import { Calendar, Clock, MapPin, Car, Trophy } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Calendar } from 'lucide-react';
|
||||
import type { LeagueScheduleViewData } from '@/lib/view-data/leagues/LeagueScheduleViewData';
|
||||
import { ScheduleRaceCard } from '@/components/leagues/ScheduleRaceCard';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface LeagueScheduleTemplateProps {
|
||||
viewData: LeagueScheduleViewData;
|
||||
@@ -9,82 +18,33 @@ interface LeagueScheduleTemplateProps {
|
||||
|
||||
export function LeagueScheduleTemplate({ viewData }: LeagueScheduleTemplateProps) {
|
||||
return (
|
||||
<Section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Race Schedule</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Upcoming and completed races for this season
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{viewData.races.length === 0 ? (
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
|
||||
<Calendar className="w-8 h-8 text-performance-green" />
|
||||
</div>
|
||||
<p className="font-semibold text-lg text-white mb-2">No Races Scheduled</p>
|
||||
<p className="text-sm text-gray-400">The race schedule will appear here once events are added.</p>
|
||||
</div>
|
||||
<Stack align="center" py={12} gap={4}>
|
||||
<Surface variant="muted" rounded="full" padding={4} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)' }}>
|
||||
<Icon icon={Calendar} size={8} color="#10b981" />
|
||||
</Surface>
|
||||
<Box style={{ textAlign: 'center' }}>
|
||||
<Text weight="semibold" size="lg" color="text-white" block mb={2}>No Races Scheduled</Text>
|
||||
<Text size="sm" color="text-gray-400">The race schedule will appear here once events are added.</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Stack gap={4}>
|
||||
{viewData.races.map((race) => (
|
||||
<Card key={race.id}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`w-3 h-3 rounded-full ${race.isPast ? 'bg-performance-green' : 'bg-primary-blue'}`} />
|
||||
<h3 className="font-semibold text-white text-lg">{race.name}</h3>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
race.status === 'completed'
|
||||
? 'bg-performance-green/20 text-performance-green'
|
||||
: 'bg-primary-blue/20 text-primary-blue'
|
||||
}`}>
|
||||
{race.status === 'completed' ? 'Completed' : 'Scheduled'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<Calendar className="w-4 h-4 text-gray-400" />
|
||||
<span>{new Date(race.scheduledAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<Clock className="w-4 h-4 text-gray-400" />
|
||||
<span>{new Date(race.scheduledAt).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
|
||||
{race.track && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<MapPin className="w-4 h-4 text-gray-400" />
|
||||
<span className="truncate">{race.track}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{race.car && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<Car className="w-4 h-4 text-gray-400" />
|
||||
<span className="truncate">{race.car}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{race.sessionType && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Trophy className="w-4 h-4" />
|
||||
<span>{race.sessionType} Session</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<ScheduleRaceCard key={race.id} race={race} />
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</Section>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { LeagueSettingsViewData } from '@/lib/view-data/leagues/LeagueSettingsViewData';
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Section } from '@/ui/Section';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
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 } from 'lucide-react';
|
||||
import type { LeagueSettingsViewData } from '@/lib/view-data/leagues/LeagueSettingsViewData';
|
||||
|
||||
interface LeagueSettingsTemplateProps {
|
||||
viewData: LeagueSettingsViewData;
|
||||
@@ -9,113 +19,98 @@ interface LeagueSettingsTemplateProps {
|
||||
|
||||
export function LeagueSettingsTemplate({ viewData }: LeagueSettingsTemplateProps) {
|
||||
return (
|
||||
<Section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">League Settings</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Manage your league configuration and preferences
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Stack gap={6}>
|
||||
{/* League Information */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10">
|
||||
<Settings className="w-5 h-5 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">League Information</h3>
|
||||
<p className="text-sm text-gray-400">Basic league details</p>
|
||||
</div>
|
||||
</div>
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)' }}>
|
||||
<Icon icon={Settings} size={5} color="#3b82f6" />
|
||||
</Surface>
|
||||
<Box>
|
||||
<Heading level={3}>League Information</Heading>
|
||||
<Text size="sm" color="text-gray-400">Basic league details</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Name</label>
|
||||
<p className="text-white">{viewData.league.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Visibility</label>
|
||||
<p className="text-white capitalize">{viewData.league.visibility}</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Description</label>
|
||||
<p className="text-white">{viewData.league.description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Created</label>
|
||||
<p className="text-white">{new Date(viewData.league.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Owner ID</label>
|
||||
<p className="text-white font-mono text-sm">{viewData.league.ownerId}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Grid cols={2} gap={4}>
|
||||
<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={new Date(viewData.league.createdAt).toLocaleDateString()} />
|
||||
<InfoItem label="Owner ID" value={viewData.league.ownerId} mono />
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Configuration */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10">
|
||||
<Trophy className="w-5 h-5 text-performance-green" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Configuration</h3>
|
||||
<p className="text-sm text-gray-400">League rules and limits</p>
|
||||
</div>
|
||||
</div>
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)' }}>
|
||||
<Icon icon={Trophy} size={5} color="#10b981" />
|
||||
</Surface>
|
||||
<Box>
|
||||
<Heading level={3}>Configuration</Heading>
|
||||
<Text size="sm" color="text-gray-400">League rules and limits</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-400">Max Drivers</p>
|
||||
<p className="text-white">{viewData.config.maxDrivers}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-400">Require Approval</p>
|
||||
<p className="text-white">{viewData.config.requireApproval ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-400">Allow Late Join</p>
|
||||
<p className="text-white">{viewData.config.allowLateJoin ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Trophy className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-400">Scoring Preset</p>
|
||||
<p className="text-white font-mono text-sm">{viewData.config.scoringPresetId}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Grid cols={2} gap={4}>
|
||||
<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} mono />
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Note about forms */}
|
||||
<Card>
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-warning-amber/10 flex items-center justify-center">
|
||||
<Settings className="w-8 h-8 text-warning-amber" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">Settings Management</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Form-based editing and ownership transfer functionality will be implemented in future updates.
|
||||
</p>
|
||||
</div>
|
||||
<Stack align="center" py={8} gap={4}>
|
||||
<Surface variant="muted" rounded="full" padding={4} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)' }}>
|
||||
<Icon icon={Settings} size={8} color="#f59e0b" />
|
||||
</Surface>
|
||||
<Box style={{ textAlign: 'center' }}>
|
||||
<Heading level={3}>Settings Management</Heading>
|
||||
<Text size="sm" color="text-gray-400" block mt={2}>
|
||||
Form-based editing and ownership transfer functionality will be implemented in future updates.
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function InfoItem({ label, value, capitalize, mono }: { label: string, value: string, capitalize?: boolean, mono?: boolean }) {
|
||||
return (
|
||||
<Box>
|
||||
<Text size="sm" weight="medium" color="text-gray-400" block mb={1}>{label}</Text>
|
||||
<Text color="text-white" style={{ textTransform: capitalize ? 'capitalize' : 'none', fontFamily: mono ? 'monospace' : 'inherit' }}>{value}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigItem({ icon, label, value, mono }: { icon: React.ElementType, label: string, value: string | number, mono?: boolean }) {
|
||||
return (
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Icon icon={icon as any} size={5} color="#9ca3af" />
|
||||
<Box>
|
||||
<Text size="sm" weight="medium" color="text-gray-400" block>{label}</Text>
|
||||
<Text color="text-white" style={{ fontFamily: mono ? 'monospace' : 'inherit' }}>{value}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { LeagueSponsorshipsViewData } from '@/lib/view-data/leagues/LeagueSponsorshipsViewData';
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Section } from '@/ui/Section';
|
||||
import { Building, DollarSign, Clock, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Building, Clock } from 'lucide-react';
|
||||
import type { LeagueSponsorshipsViewData } from '@/lib/view-data/leagues/LeagueSponsorshipsViewData';
|
||||
import { SponsorshipSlotCard } from '@/components/leagues/SponsorshipSlotCard';
|
||||
import { SponsorshipRequestCard } from '@/components/leagues/SponsorshipRequestCard';
|
||||
|
||||
interface LeagueSponsorshipsTemplateProps {
|
||||
viewData: LeagueSponsorshipsViewData;
|
||||
@@ -9,160 +20,96 @@ interface LeagueSponsorshipsTemplateProps {
|
||||
|
||||
export function LeagueSponsorshipsTemplate({ viewData }: LeagueSponsorshipsTemplateProps) {
|
||||
return (
|
||||
<Section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Sponsorships</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Manage sponsorship slots and review requests
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Stack gap={6}>
|
||||
<Box>
|
||||
<Heading level={2}>Sponsorships</Heading>
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>
|
||||
Manage sponsorship slots and review requests
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Stack gap={6}>
|
||||
{/* Sponsorship Slots */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10">
|
||||
<Building className="w-5 h-5 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Sponsorship Slots</h3>
|
||||
<p className="text-sm text-gray-400">Available sponsorship opportunities</p>
|
||||
</div>
|
||||
</div>
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)' }}>
|
||||
<Icon icon={Building} size={5} color="#3b82f6" />
|
||||
</Surface>
|
||||
<Box>
|
||||
<Heading level={3}>Sponsorship Slots</Heading>
|
||||
<Text size="sm" color="text-gray-400">Available sponsorship opportunities</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{viewData.sponsorshipSlots.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Building className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
||||
<p className="text-gray-400">No sponsorship slots available</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{viewData.sponsorshipSlots.map((slot) => (
|
||||
<div
|
||||
key={slot.id}
|
||||
className={`rounded-lg border p-4 ${
|
||||
slot.isAvailable
|
||||
? 'border-performance-green bg-performance-green/5'
|
||||
: 'border-charcoal-outline bg-iron-gray/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h4 className="font-semibold text-white">{slot.name}</h4>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
slot.isAvailable
|
||||
? 'bg-performance-green/20 text-performance-green'
|
||||
: 'bg-gray-500/20 text-gray-400'
|
||||
}`}>
|
||||
{slot.isAvailable ? 'Available' : 'Taken'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-300 mb-3">{slot.description}</p>
|
||||
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<DollarSign className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-white font-semibold">
|
||||
{slot.price} {slot.currency}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!slot.isAvailable && slot.sponsoredBy && (
|
||||
<div className="pt-3 border-t border-charcoal-outline">
|
||||
<p className="text-xs text-gray-400 mb-1">Sponsored by</p>
|
||||
<p className="text-sm font-medium text-white">{slot.sponsoredBy.name}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{viewData.sponsorshipSlots.length === 0 ? (
|
||||
<Stack align="center" py={8} gap={4}>
|
||||
<Icon icon={Building} size={12} color="#525252" />
|
||||
<Text color="text-gray-400">No sponsorship slots available</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Grid cols={3} gap={4}>
|
||||
{viewData.sponsorshipSlots.map((slot) => (
|
||||
<SponsorshipSlotCard key={slot.id} slot={slot} />
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Sponsorship Requests */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/10">
|
||||
<Clock className="w-5 h-5 text-warning-amber" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Sponsorship Requests</h3>
|
||||
<p className="text-sm text-gray-400">Pending and processed sponsorship applications</p>
|
||||
</div>
|
||||
</div>
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)' }}>
|
||||
<Icon icon={Clock} size={5} color="#f59e0b" />
|
||||
</Surface>
|
||||
<Box>
|
||||
<Heading level={3}>Sponsorship Requests</Heading>
|
||||
<Text size="sm" color="text-gray-400">Pending and processed sponsorship applications</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{viewData.sponsorshipRequests.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Clock className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
||||
<p className="text-gray-400">No sponsorship requests</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{viewData.sponsorshipRequests.map((request) => {
|
||||
const slot = viewData.sponsorshipSlots.find(s => s.id === request.slotId);
|
||||
const statusIcon = {
|
||||
pending: <AlertCircle className="w-5 h-5 text-warning-amber" />,
|
||||
approved: <CheckCircle className="w-5 h-5 text-performance-green" />,
|
||||
rejected: <XCircle className="w-5 h-5 text-red-400" />,
|
||||
}[request.status];
|
||||
|
||||
const statusColor = {
|
||||
pending: 'border-warning-amber bg-warning-amber/5',
|
||||
approved: 'border-performance-green bg-performance-green/5',
|
||||
rejected: 'border-red-400 bg-red-400/5',
|
||||
}[request.status];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={request.id}
|
||||
className={`rounded-lg border p-4 ${statusColor}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{statusIcon}
|
||||
<span className="font-semibold text-white">{request.sponsorName}</span>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
request.status === 'pending'
|
||||
? 'bg-warning-amber/20 text-warning-amber'
|
||||
: request.status === 'approved'
|
||||
? 'bg-performance-green/20 text-performance-green'
|
||||
: 'bg-red-400/20 text-red-400'
|
||||
}`}>
|
||||
{request.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-300 mb-2">
|
||||
Requested: {slot?.name || 'Unknown slot'}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-400">
|
||||
{new Date(request.requestedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{viewData.sponsorshipRequests.length === 0 ? (
|
||||
<Stack align="center" py={8} gap={4}>
|
||||
<Icon icon={Clock} size={12} color="#525252" />
|
||||
<Text color="text-gray-400">No sponsorship requests</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={3}>
|
||||
{viewData.sponsorshipRequests.map((request) => {
|
||||
const slot = viewData.sponsorshipSlots.find(s => s.id === request.slotId);
|
||||
return (
|
||||
<SponsorshipRequestCard
|
||||
key={request.id}
|
||||
request={{
|
||||
...request,
|
||||
status: request.status as any,
|
||||
slotName: slot?.name || 'Unknown slot'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Note about management */}
|
||||
<Card>
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-primary-blue/10 flex items-center justify-center">
|
||||
<Building className="w-8 h-8 text-primary-blue" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">Sponsorship Management</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Interactive management features for approving requests and managing slots will be implemented in future updates.
|
||||
</p>
|
||||
</div>
|
||||
<Stack align="center" py={8} gap={4}>
|
||||
<Surface variant="muted" rounded="full" padding={4} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)' }}>
|
||||
<Icon icon={Building} size={8} color="#3b82f6" />
|
||||
</Surface>
|
||||
<Box style={{ textAlign: 'center' }}>
|
||||
<Heading level={3}>Sponsorship Management</Heading>
|
||||
<Text size="sm" color="text-gray-400" block mt={2}>
|
||||
Interactive management features for approving requests and managing slots will be implemented in future updates.
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { LeagueChampionshipStats } from '@/components/leagues/LeagueChampionshipStats';
|
||||
import { StandingsTable } from '@/components/leagues/StandingsTable';
|
||||
import Card from '@/components/ui/Card';
|
||||
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 type { LeagueStandingsViewData } from '@/lib/view-data/LeagueStandingsViewData';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
interface LeagueStandingsTemplateProps {
|
||||
viewData: LeagueStandingsViewData;
|
||||
onRemoveMember: (driverId: string) => void;
|
||||
@@ -16,10 +17,6 @@ interface LeagueStandingsTemplateProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN TEMPLATE COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function LeagueStandingsTemplate({
|
||||
viewData,
|
||||
onRemoveMember,
|
||||
@@ -28,29 +25,31 @@ export function LeagueStandingsTemplate({
|
||||
}: LeagueStandingsTemplateProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center text-gray-400">
|
||||
Loading standings...
|
||||
</div>
|
||||
<Stack align="center" py={12}>
|
||||
<Text color="text-gray-400">Loading standings...</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Stack gap={6}>
|
||||
{/* Championship Stats */}
|
||||
<LeagueChampionshipStats standings={viewData.standings} drivers={viewData.drivers} />
|
||||
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Championship Standings</h2>
|
||||
<StandingsTable
|
||||
standings={viewData.standings}
|
||||
drivers={viewData.drivers}
|
||||
memberships={viewData.memberships}
|
||||
currentDriverId={viewData.currentDriverId ?? undefined}
|
||||
isAdmin={viewData.isAdmin}
|
||||
onRemoveMember={onRemoveMember}
|
||||
onUpdateRole={onUpdateRole}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,155 +1,90 @@
|
||||
import { LeagueWalletViewData } from '@/lib/view-data/leagues/LeagueWalletViewData';
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Section } from '@/ui/Section';
|
||||
import { Wallet, TrendingUp, TrendingDown, DollarSign, Calendar, ArrowUpRight, ArrowDownRight } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Wallet, Calendar } from 'lucide-react';
|
||||
import type { LeagueWalletViewData } from '@/lib/view-data/leagues/LeagueWalletViewData';
|
||||
import { TransactionRow } from '@/components/leagues/TransactionRow';
|
||||
|
||||
interface LeagueWalletTemplateProps {
|
||||
viewData: LeagueWalletViewData;
|
||||
}
|
||||
|
||||
export function LeagueWalletTemplate({ viewData }: LeagueWalletTemplateProps) {
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: viewData.currency,
|
||||
}).format(Math.abs(amount));
|
||||
};
|
||||
|
||||
const getTransactionIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'deposit':
|
||||
return <ArrowUpRight className="w-4 h-4 text-performance-green" />;
|
||||
case 'withdrawal':
|
||||
return <ArrowDownRight className="w-4 h-4 text-red-400" />;
|
||||
case 'sponsorship':
|
||||
return <DollarSign className="w-4 h-4 text-primary-blue" />;
|
||||
case 'prize':
|
||||
return <TrendingUp className="w-4 h-4 text-warning-amber" />;
|
||||
default:
|
||||
return <DollarSign className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTransactionColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'deposit':
|
||||
return 'text-performance-green';
|
||||
case 'withdrawal':
|
||||
return 'text-red-400';
|
||||
case 'sponsorship':
|
||||
return 'text-primary-blue';
|
||||
case 'prize':
|
||||
return 'text-warning-amber';
|
||||
default:
|
||||
return 'text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">League Wallet</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Financial overview and transaction history
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Stack gap={6}>
|
||||
<Box>
|
||||
<Heading level={2}>League Wallet</Heading>
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>
|
||||
Financial overview and transaction history
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Stack gap={6}>
|
||||
{/* Balance Card */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
|
||||
<Wallet className="w-6 h-6 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Current Balance</p>
|
||||
<p className="text-3xl font-bold text-white">
|
||||
{formatCurrency(viewData.balance)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Surface variant="muted" rounded="xl" padding={3} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)' }}>
|
||||
<Icon icon={Wallet} size={6} color="#3b82f6" />
|
||||
</Surface>
|
||||
<Box>
|
||||
<Text size="sm" color="text-gray-400" block>Current Balance</Text>
|
||||
<Text size="3xl" weight="bold" color="text-white">
|
||||
{viewData.formattedBalance}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Transaction History */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10">
|
||||
<Calendar className="w-5 h-5 text-performance-green" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Transaction History</h3>
|
||||
<p className="text-sm text-gray-400">Recent financial activity</p>
|
||||
</div>
|
||||
</div>
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)' }}>
|
||||
<Icon icon={Calendar} size={5} color="#10b981" />
|
||||
</Surface>
|
||||
<Box>
|
||||
<Heading level={3}>Transaction History</Heading>
|
||||
<Text size="sm" color="text-gray-400">Recent financial activity</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{viewData.transactions.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Wallet className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
||||
<p className="text-gray-400">No transactions yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{viewData.transactions.map((transaction) => (
|
||||
<div
|
||||
key={transaction.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg border border-charcoal-outline bg-iron-gray/30"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
{getTransactionIcon(transaction.type)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{transaction.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span>{new Date(transaction.createdAt).toLocaleDateString()}</span>
|
||||
<span>•</span>
|
||||
<span className={`capitalize ${getTransactionColor(transaction.type)}`}>
|
||||
{transaction.type}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className={`capitalize ${
|
||||
transaction.status === 'completed'
|
||||
? 'text-performance-green'
|
||||
: transaction.status === 'pending'
|
||||
? 'text-warning-amber'
|
||||
: 'text-red-400'
|
||||
}`}>
|
||||
{transaction.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<p className={`text-lg font-semibold ${
|
||||
transaction.amount >= 0 ? 'text-performance-green' : 'text-red-400'
|
||||
}`}>
|
||||
{transaction.amount >= 0 ? '+' : '-'}{formatCurrency(transaction.amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{viewData.transactions.length === 0 ? (
|
||||
<Stack align="center" py={8} gap={4}>
|
||||
<Icon icon={Wallet} size={12} color="#525252" />
|
||||
<Text color="text-gray-400">No transactions yet</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={3}>
|
||||
{viewData.transactions.map((transaction) => (
|
||||
<TransactionRow key={transaction.id} transaction={transaction} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Note about features */}
|
||||
<Card>
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-primary-blue/10 flex items-center justify-center">
|
||||
<Wallet className="w-8 h-8 text-primary-blue" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">Wallet Management</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Interactive withdrawal and export features will be implemented in future updates.
|
||||
</p>
|
||||
</div>
|
||||
<Stack align="center" py={8} gap={4}>
|
||||
<Surface variant="muted" rounded="full" padding={4} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)' }}>
|
||||
<Icon icon={Wallet} size={8} color="#3b82f6" />
|
||||
</Surface>
|
||||
<Box style={{ textAlign: 'center' }}>
|
||||
<Heading level={3}>Wallet Management</Heading>
|
||||
<Text size="sm" color="text-gray-400" block mt={2}>
|
||||
Interactive withdrawal and export features will be implemented in future updates.
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
'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 type { ProfileLeaguesViewData } from '@/lib/view-data/ProfileLeaguesViewData';
|
||||
import { LeagueListItem } from '@/components/profile/LeagueListItem';
|
||||
|
||||
interface ProfileLeaguesTemplateProps {
|
||||
viewData: ProfileLeaguesViewData;
|
||||
@@ -6,104 +16,67 @@ interface ProfileLeaguesTemplateProps {
|
||||
|
||||
export function ProfileLeaguesTemplate({ viewData }: ProfileLeaguesTemplateProps) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Manage leagues</h1>
|
||||
<p className="text-gray-400 text-sm">
|
||||
View leagues you own and participate in, and jump into league admin tools.
|
||||
</p>
|
||||
</div>
|
||||
<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 */}
|
||||
<div className="bg-charcoal rounded-lg border border-charcoal-outline p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-white">Leagues you own</h2>
|
||||
{viewData.ownedLeagues.length > 0 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{viewData.ownedLeagues.length} {viewData.ownedLeagues.length === 1 ? 'league' : 'leagues'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 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 ? (
|
||||
<p className="text-sm text-gray-400">
|
||||
You don't own any leagues yet in this session.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{viewData.ownedLeagues.map((league: ProfileLeaguesViewData['ownedLeagues'][number]) => (
|
||||
<div
|
||||
key={league.leagueId}
|
||||
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-white font-medium">{league.name}</h3>
|
||||
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
|
||||
{league.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={`/leagues/${league.leagueId}`}
|
||||
className="text-sm text-gray-300 hover:text-white underline-offset-2 hover:underline"
|
||||
>
|
||||
View
|
||||
</a>
|
||||
<a href={`/leagues/${league.leagueId}?tab=admin`}>
|
||||
<button className="bg-primary hover:bg-primary/90 text-white text-xs px-3 py-1.5 rounded transition-colors">
|
||||
Manage
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{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 */}
|
||||
<div className="bg-charcoal rounded-lg border border-charcoal-outline p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-white">Leagues you're in</h2>
|
||||
{viewData.memberLeagues.length > 0 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{viewData.memberLeagues.length} {viewData.memberLeagues.length === 1 ? 'league' : 'leagues'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 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 ? (
|
||||
<p className="text-sm text-gray-400">
|
||||
You're not a member of any other leagues yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{viewData.memberLeagues.map((league: ProfileLeaguesViewData['memberLeagues'][number]) => (
|
||||
<div
|
||||
key={league.leagueId}
|
||||
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-white font-medium">{league.name}</h3>
|
||||
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
|
||||
{league.description}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Your role:{' '}
|
||||
{league.membershipRole.charAt(0).toUpperCase() + league.membershipRole.slice(1)}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={`/leagues/${league.leagueId}`}
|
||||
className="text-sm text-gray-300 hover:text-white underline-offset-2 hover:underline"
|
||||
>
|
||||
View league
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,447 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
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/profile/AchievementGrid';
|
||||
import { ProfileHero } from '@/components/profile/ProfileHero';
|
||||
import { ProfileStatGrid } from '@/components/profile/ProfileStatGrid';
|
||||
import { ProfileTabs } from '@/components/profile/ProfileTabs';
|
||||
import { TeamMembershipGrid } from '@/components/profile/TeamMembershipGrid';
|
||||
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,
|
||||
BarChart3,
|
||||
Calendar,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Edit3,
|
||||
ExternalLink,
|
||||
Flag,
|
||||
Globe,
|
||||
History,
|
||||
MessageCircle,
|
||||
Percent,
|
||||
Settings,
|
||||
Shield,
|
||||
Star,
|
||||
Target,
|
||||
TrendingUp,
|
||||
Trophy,
|
||||
Twitch,
|
||||
Twitter,
|
||||
User,
|
||||
UserPlus,
|
||||
Users,
|
||||
Youtube,
|
||||
Zap,
|
||||
Medal,
|
||||
Crown,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import ProfileSettings from '@/components/drivers/ProfileSettings';
|
||||
import ProfileRaceHistory from '@/components/drivers/ProfileRaceHistory';
|
||||
import CreateDriverForm from '@/components/drivers/CreateDriverForm';
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
|
||||
type ProfileTab = 'overview' | 'history' | 'stats';
|
||||
export type ProfileTab = 'overview' | 'history' | 'stats';
|
||||
|
||||
interface ProfileTemplateProps {
|
||||
viewData: ProfileViewData | null;
|
||||
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>;
|
||||
}
|
||||
|
||||
function getAchievementIcon(icon: NonNullable<ProfileViewData['extendedProfile']>['achievements'][number]['icon']) {
|
||||
switch (icon) {
|
||||
case 'trophy':
|
||||
return Trophy;
|
||||
case 'medal':
|
||||
return Medal;
|
||||
case 'star':
|
||||
return Star;
|
||||
case 'crown':
|
||||
return Crown;
|
||||
case 'target':
|
||||
return Target;
|
||||
case 'zap':
|
||||
return Zap;
|
||||
}
|
||||
}
|
||||
|
||||
function getSocialIcon(platformLabel: string) {
|
||||
switch (platformLabel) {
|
||||
case 'twitter':
|
||||
return Twitter;
|
||||
case 'youtube':
|
||||
return Youtube;
|
||||
case 'twitch':
|
||||
return Twitch;
|
||||
case 'discord':
|
||||
return MessageCircle;
|
||||
default:
|
||||
return Globe;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProfileTemplate({ viewData, mode, onSaveSettings }: ProfileTemplateProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const tabParam = searchParams.get('tab') as ProfileTab | null;
|
||||
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>(tabParam || 'overview');
|
||||
const [friendRequestSent, setFriendRequestSent] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('tab') !== activeTab) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (activeTab === 'overview') {
|
||||
params.delete('tab');
|
||||
} else {
|
||||
params.set('tab', activeTab);
|
||||
}
|
||||
const query = params.toString();
|
||||
router.replace(`/profile${query ? `?${query}` : ''}`, { scroll: false });
|
||||
}
|
||||
}, [activeTab, searchParams, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const tab = searchParams.get('tab') as ProfileTab | null;
|
||||
if (tab && tab !== activeTab) {
|
||||
setActiveTab(tab);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
export function ProfileTemplate({
|
||||
viewData,
|
||||
mode,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
editMode,
|
||||
onEditModeChange,
|
||||
friendRequestSent,
|
||||
onFriendRequestSend,
|
||||
onSaveSettings,
|
||||
}: ProfileTemplateProps) {
|
||||
if (mode === 'needs-profile') {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30 mx-auto mb-4">
|
||||
<User className="w-8 h-8 text-primary-blue" />
|
||||
</div>
|
||||
<Heading level={1} className="mb-2">Create Your Driver Profile</Heading>
|
||||
<p className="text-gray-400">Join the GridPilot community and start your racing journey</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Get Started</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Create your driver profile to join leagues, compete in races, and connect with other drivers.
|
||||
</p>
|
||||
</div>
|
||||
<CreateDriverForm />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!viewData) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<Card className="text-center py-12">
|
||||
<User className="w-16 h-16 text-gray-600 mx-auto mb-4" />
|
||||
<p className="text-gray-400 mb-2">Unable to load profile</p>
|
||||
</Card>
|
||||
</div>
|
||||
<Box maxWidth="42rem" mx="auto">
|
||||
<Card>
|
||||
<Stack gap={6}>
|
||||
<Box>
|
||||
<Heading level={2}>Get Started</Heading>
|
||||
<Text size="sm" color="text-gray-400">
|
||||
Create your driver profile to join leagues, compete in races, and connect with other drivers.
|
||||
</Text>
|
||||
</Box>
|
||||
<CreateDriverForm />
|
||||
</Stack>
|
||||
</Card>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (editMode) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 space-y-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Heading level={1}>Edit Profile</Heading>
|
||||
<Button variant="secondary" onClick={() => setEditMode(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<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 expects a DriverProfileDriverSummaryViewModel; keep existing component usage by passing a minimal compatible shape */}
|
||||
<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);
|
||||
setEditMode(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 pb-12 space-y-6">
|
||||
{/* Hero */}
|
||||
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-iron-gray/80 via-iron-gray/60 to-deep-graphite border border-charcoal-outline">
|
||||
<div className="relative p-6 md:p-8">
|
||||
<div className="flex flex-col md:flex-row md:items-start gap-6">
|
||||
<div className="relative">
|
||||
<div className="w-28 h-28 md:w-36 md:h-36 rounded-2xl bg-gradient-to-br from-primary-blue to-purple-600 p-1 shadow-xl shadow-primary-blue/20">
|
||||
<div className="w-full h-full rounded-xl overflow-hidden bg-iron-gray">
|
||||
<Image
|
||||
src={viewData.driver.avatarUrl}
|
||||
alt={viewData.driver.name}
|
||||
width={144}
|
||||
height={144}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-full bg-performance-green border-4 border-iron-gray" />
|
||||
</div>
|
||||
<Container size="lg">
|
||||
<Stack gap={6}>
|
||||
{/* Back Navigation */}
|
||||
<Box>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {}}
|
||||
icon={<Icon icon={History} size={4} />}
|
||||
>
|
||||
Back to Drivers
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white">{viewData.driver.name}</h1>
|
||||
<span className="text-4xl" aria-label={`Country: ${viewData.driver.countryCode}`}>{viewData.driver.countryFlag}</span>
|
||||
{viewData.teamMemberships[0] && (
|
||||
<span className="px-3 py-1 bg-purple-600/20 text-purple-400 rounded-full text-sm font-semibold border border-purple-600/30">
|
||||
[{viewData.teamMemberships[0].teamTag || 'TEAM'}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Drivers', href: '/drivers' },
|
||||
{ label: viewData.driver.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
{viewData.stats && (
|
||||
<div className="flex flex-wrap items-center gap-4 mb-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/30">
|
||||
<Star className="w-4 h-4 text-primary-blue" />
|
||||
<span className="font-mono font-bold text-primary-blue">{viewData.stats.ratingLabel}</span>
|
||||
<span className="text-xs text-gray-400">Rating</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-400/10 border border-yellow-400/30">
|
||||
<Trophy className="w-4 h-4 text-yellow-400" />
|
||||
<span className="font-mono font-bold text-yellow-400">{viewData.stats.globalRankLabel}</span>
|
||||
<span className="text-xs text-gray-400">Global</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<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 as any })) || []}
|
||||
onAddFriend={onFriendRequestSend}
|
||||
friendRequestSent={friendRequestSent}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-400">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Globe className="w-4 h-4" />
|
||||
iRacing: {viewData.driver.iracingId ?? '—'}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Joined {viewData.driver.joinedAtLabel}
|
||||
</span>
|
||||
{viewData.extendedProfile && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="w-4 h-4" />
|
||||
{viewData.extendedProfile.timezone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button variant="primary" onClick={() => setEditMode(true)} className="flex items-center gap-2">
|
||||
<Edit3 className="w-4 h-4" />
|
||||
Edit Profile
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setFriendRequestSent(true)}
|
||||
disabled={friendRequestSent}
|
||||
className="w-full flex items-center gap-2"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
|
||||
</Button>
|
||||
<Link href=routes.protected.profileLeagues>
|
||||
<Button variant="secondary" className="w-full flex items-center gap-2">
|
||||
<Flag className="w-4 h-4" />
|
||||
My Leagues
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewData.extendedProfile && viewData.extendedProfile.socialHandles.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-charcoal-outline/50">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-gray-500 mr-2">Connect:</span>
|
||||
{viewData.extendedProfile.socialHandles.map((social) => {
|
||||
const Icon = getSocialIcon(social.platformLabel);
|
||||
return (
|
||||
<a
|
||||
key={`${social.platformLabel}-${social.handle}`}
|
||||
href={social.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-iron-gray/50 border border-charcoal-outline text-gray-400 transition-all hover:text-white"
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="text-sm">{social.handle}</span>
|
||||
<ExternalLink className="w-3 h-3 opacity-50" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewData.driver.bio && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-primary-blue" />
|
||||
About
|
||||
</h2>
|
||||
<p className="text-gray-300 leading-relaxed">{viewData.driver.bio}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{viewData.teamMemberships.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-purple-400" />
|
||||
Team Memberships
|
||||
<span className="text-sm text-gray-500 font-normal">({viewData.teamMemberships.length})</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{viewData.teamMemberships.map((membership) => (
|
||||
<Link
|
||||
key={membership.teamId}
|
||||
href={membership.href}
|
||||
className="flex items-center gap-4 p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline hover:border-purple-400/30 hover:bg-iron-gray/50 transition-all group"
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-600/20 border border-purple-600/30">
|
||||
<Users className="w-6 h-6 text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-semibold truncate group-hover:text-purple-400 transition-colors">{membership.teamName}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span className="px-2 py-0.5 rounded-full bg-purple-600/20 text-purple-400 capitalize">{membership.roleLabel}</span>
|
||||
<span>Since {membership.joinedAtLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500 group-hover:text-purple-400 transition-colors" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 p-1.5 rounded-xl bg-iron-gray/50 border border-charcoal-outline w-fit relative z-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all cursor-pointer select-none ${
|
||||
activeTab === 'overview'
|
||||
? 'bg-primary-blue text-white shadow-lg shadow-primary-blue/25'
|
||||
: 'text-gray-400 hover:text-white hover:bg-iron-gray/80'
|
||||
}`}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('history')}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all cursor-pointer select-none ${
|
||||
activeTab === 'history'
|
||||
? 'bg-primary-blue text-white shadow-lg shadow-primary-blue/25'
|
||||
: 'text-gray-400 hover:text-white hover:bg-iron-gray/80'
|
||||
}`}
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
Race History
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('stats')}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all cursor-pointer select-none ${
|
||||
activeTab === 'stats'
|
||||
? 'bg-primary-blue text-white shadow-lg shadow-primary-blue/25'
|
||||
: 'text-gray-400 hover:text-white hover:bg-iron-gray/80'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
Detailed Stats
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<History className="w-5 h-5 text-red-400" />
|
||||
Race History
|
||||
</h2>
|
||||
<ProfileRaceHistory driverId={viewData.driver.id} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'stats' && viewData.stats && (
|
||||
<div className="space-y-6">
|
||||
{viewData.driver.bio && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-neon-aqua" />
|
||||
Performance Overview
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
|
||||
<div className="text-3xl font-bold text-white mb-1">{viewData.stats.totalRacesLabel}</div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wider">Races</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
|
||||
<div className="text-3xl font-bold text-performance-green mb-1">{viewData.stats.winsLabel}</div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wider">Wins</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
|
||||
<div className="text-3xl font-bold text-warning-amber mb-1">{viewData.stats.podiumsLabel}</div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wider">Podiums</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
|
||||
<div className="text-3xl font-bold text-primary-blue mb-1">{viewData.stats.consistencyLabel}</div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wider">Consistency</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{activeTab === 'overview' && viewData.extendedProfile && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Award className="w-5 h-5 text-yellow-400" />
|
||||
Achievements
|
||||
<span className="ml-auto text-sm text-gray-500">{viewData.extendedProfile.achievements.length} earned</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{viewData.extendedProfile.achievements.map((achievement) => {
|
||||
const Icon = getAchievementIcon(achievement.icon);
|
||||
return (
|
||||
<div key={achievement.id} className="p-4 rounded-xl border border-charcoal-outline bg-iron-gray/30">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-iron-gray/50 border border-charcoal-outline">
|
||||
<Icon className="w-5 h-5 text-yellow-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-semibold text-sm">{achievement.title}</p>
|
||||
<p className="text-gray-400 text-xs mt-0.5">{achievement.description}</p>
|
||||
<p className="text-gray-500 text-xs mt-1">{achievement.earnedAtLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
{viewData.teamMemberships.length > 0 && (
|
||||
<TeamMembershipGrid
|
||||
memberships={viewData.teamMemberships.map(m => ({
|
||||
team: { id: m.teamId, name: m.teamName },
|
||||
role: m.roleLabel,
|
||||
joinedAt: new Date() // Placeholder
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProfileTabs activeTab={activeTab as any} onTabChange={onTabChange as any} />
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{activeTab === 'stats' && viewData.stats && (
|
||||
<Card>
|
||||
<Stack gap={6}>
|
||||
<Heading level={2} icon={<Icon icon={Activity} size={5} color="#00f2ff" />}>
|
||||
Performance Overview
|
||||
</Heading>
|
||||
<ProfileStatGrid
|
||||
stats={[
|
||||
{ label: 'Races', value: viewData.stats.totalRacesLabel },
|
||||
{ label: 'Wins', value: viewData.stats.winsLabel, color: '#10b981' },
|
||||
{ label: 'Podiums', value: viewData.stats.podiumsLabel, color: '#f59e0b' },
|
||||
{ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
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 { Grid } from '@/ui/Grid';
|
||||
import { GridItem } from '@/ui/GridItem';
|
||||
import { Skeleton } from '@/ui/Skeleton';
|
||||
import { InfoBox } from '@/ui/InfoBox';
|
||||
import { RaceJoinButton } from '@/components/races/RaceJoinButton';
|
||||
import { RaceHero } from '@/components/races/RaceHero';
|
||||
import { RaceUserResult } from '@/components/races/RaceUserResult';
|
||||
import { RaceEntryList } from '@/components/races/RaceEntryList';
|
||||
import { RaceDetailCard } from '@/components/races/RaceDetailCard';
|
||||
import { LeagueSummaryCard } from '@/components/leagues/LeagueSummaryCard';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
Car,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Flag,
|
||||
PlayCircle,
|
||||
Scale,
|
||||
Trophy,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
Users,
|
||||
XCircle,
|
||||
Zap,
|
||||
Scale,
|
||||
} from 'lucide-react';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Card } from '@/ui/Card';
|
||||
|
||||
export interface RaceDetailEntryViewModel {
|
||||
id: string;
|
||||
@@ -69,7 +74,7 @@ export interface RaceDetailRegistration {
|
||||
canRegister: boolean;
|
||||
}
|
||||
|
||||
export interface RaceDetailViewModel {
|
||||
export interface RaceDetailViewData {
|
||||
race: RaceDetailRace;
|
||||
league?: RaceDetailLeague;
|
||||
entryList: RaceDetailEntryViewModel[];
|
||||
@@ -79,7 +84,7 @@ export interface RaceDetailViewModel {
|
||||
}
|
||||
|
||||
export interface RaceDetailTemplateProps {
|
||||
viewModel?: RaceDetailViewModel;
|
||||
viewData?: RaceDetailViewData;
|
||||
isLoading: boolean;
|
||||
error?: Error | null;
|
||||
// Actions
|
||||
@@ -98,10 +103,7 @@ export interface RaceDetailTemplateProps {
|
||||
currentDriverId?: string;
|
||||
isOwnerOrAdmin?: boolean;
|
||||
// UI State
|
||||
showProtestModal: boolean;
|
||||
setShowProtestModal: (show: boolean) => void;
|
||||
showEndRaceModal: boolean;
|
||||
setShowEndRaceModal: (show: boolean) => void;
|
||||
animatedRatingChange: number;
|
||||
// Loading states
|
||||
mutationLoading?: {
|
||||
register?: boolean;
|
||||
@@ -113,7 +115,7 @@ export interface RaceDetailTemplateProps {
|
||||
}
|
||||
|
||||
export function RaceDetailTemplate({
|
||||
viewModel,
|
||||
viewData,
|
||||
isLoading,
|
||||
error,
|
||||
onBack,
|
||||
@@ -125,183 +127,88 @@ export function RaceDetailTemplate({
|
||||
onFileProtest,
|
||||
onResultsClick,
|
||||
onStewardingClick,
|
||||
onLeagueClick,
|
||||
onDriverClick,
|
||||
currentDriverId,
|
||||
isOwnerOrAdmin = false,
|
||||
showProtestModal,
|
||||
setShowProtestModal,
|
||||
showEndRaceModal,
|
||||
setShowEndRaceModal,
|
||||
animatedRatingChange,
|
||||
mutationLoading = {},
|
||||
}: RaceDetailTemplateProps) {
|
||||
const [ratingChange, setRatingChange] = useState<number | null>(null);
|
||||
const [animatedRatingChange, setAnimatedRatingChange] = useState(0);
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Container size="lg" py={8}>
|
||||
<Stack gap={6}>
|
||||
<Skeleton width="8rem" height="1.5rem" />
|
||||
<Skeleton width="100%" height="12rem" />
|
||||
<Grid cols={3} gap={6}>
|
||||
<GridItem colSpan={2}>
|
||||
<Skeleton width="100%" height="16rem" />
|
||||
</GridItem>
|
||||
<Skeleton width="100%" height="16rem" />
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// Set rating change when viewModel changes
|
||||
useEffect(() => {
|
||||
if (viewModel?.userResult?.ratingChange !== undefined) {
|
||||
setRatingChange(viewModel.userResult.ratingChange);
|
||||
}
|
||||
}, [viewModel?.userResult?.ratingChange]);
|
||||
if (error || !viewData || !viewData.race) {
|
||||
return (
|
||||
<Container size="md" py={8}>
|
||||
<Stack gap={6}>
|
||||
<Breadcrumbs items={[{ label: 'Races', href: '/races' }, { label: 'Error' }]} />
|
||||
|
||||
// Animate rating change when it changes
|
||||
useEffect(() => {
|
||||
if (ratingChange !== null) {
|
||||
let start = 0;
|
||||
const end = ratingChange;
|
||||
const duration = 1000;
|
||||
const startTime = performance.now();
|
||||
<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>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
const current = Math.round(start + (end - start) * eased);
|
||||
setAnimatedRatingChange(current);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
}, [ratingChange]);
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return new Date(date).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
};
|
||||
|
||||
const getTimeUntil = (date: Date) => {
|
||||
const now = new Date();
|
||||
const target = new Date(date);
|
||||
const diffMs = target.getTime() - now.getTime();
|
||||
|
||||
if (diffMs < 0) return null;
|
||||
|
||||
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
};
|
||||
const { race, league, entryList, userResult } = viewData;
|
||||
|
||||
const statusConfig = {
|
||||
scheduled: {
|
||||
icon: Clock,
|
||||
color: 'text-primary-blue',
|
||||
bg: 'bg-primary-blue/10',
|
||||
border: 'border-primary-blue/30',
|
||||
variant: 'primary' as const,
|
||||
label: 'Scheduled',
|
||||
description: 'This race is scheduled and waiting to start',
|
||||
},
|
||||
running: {
|
||||
icon: PlayCircle,
|
||||
color: 'text-performance-green',
|
||||
bg: 'bg-performance-green/10',
|
||||
border: 'border-performance-green/30',
|
||||
variant: 'success' as const,
|
||||
label: 'LIVE NOW',
|
||||
description: 'This race is currently in progress',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-gray-400',
|
||||
bg: 'bg-gray-500/10',
|
||||
border: 'border-gray-500/30',
|
||||
variant: 'default' as const,
|
||||
label: 'Completed',
|
||||
description: 'This race has finished',
|
||||
},
|
||||
cancelled: {
|
||||
icon: XCircle,
|
||||
color: 'text-warning-amber',
|
||||
bg: 'bg-warning-amber/10',
|
||||
border: 'border-warning-amber/30',
|
||||
variant: 'warning' as const,
|
||||
label: 'Cancelled',
|
||||
description: 'This race has been cancelled',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const getCountryFlag = (countryCode: string): string => {
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(char => 127397 + char.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-6 bg-iron-gray rounded w-1/4" />
|
||||
<div className="h-48 bg-iron-gray rounded-xl" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 h-64 bg-iron-gray rounded-xl" />
|
||||
<div className="h-64 bg-iron-gray rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !viewModel || !viewModel.race) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Breadcrumbs items={[{ label: 'Races', href: '/races' }, { label: 'Error' }]} />
|
||||
|
||||
<Card className="text-center py-12 mt-6">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="p-4 bg-warning-amber/10 rounded-full">
|
||||
<AlertTriangle className="w-8 h-8 text-warning-amber" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium mb-1">{error instanceof Error ? error.message : error || 'Race not found'}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
The race you're looking for doesn't exist or has been removed.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
className="mt-4"
|
||||
>
|
||||
Back to Races
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const race = viewModel.race;
|
||||
const league = viewModel.league;
|
||||
const entryList = viewModel.entryList;
|
||||
const userResult = viewModel.userResult;
|
||||
const raceSOF = null; // TODO: Add strength of field to race details response
|
||||
|
||||
const config = statusConfig[race.status as keyof typeof statusConfig];
|
||||
const StatusIcon = config.icon;
|
||||
const timeUntil = race.status === 'scheduled' ? getTimeUntil(new Date(race.scheduledAt)) : null;
|
||||
const config = statusConfig[race.status] || statusConfig.scheduled;
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: 'Races', href: '/races' },
|
||||
@@ -310,544 +217,109 @@ export function RaceDetailTemplate({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Navigation Row: Breadcrumbs left, Back button right */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Breadcrumbs items={breadcrumbItems} className="text-sm text-gray-400" />
|
||||
<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}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
size="sm"
|
||||
icon={<Icon icon={ArrowLeft} size={4} />}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{/* User Result - Premium Achievement Card */}
|
||||
{/* User Result */}
|
||||
{userResult && (
|
||||
<div
|
||||
className={`
|
||||
relative overflow-hidden rounded-2xl p-1
|
||||
${
|
||||
userResult.position === 1
|
||||
? 'bg-gradient-to-r from-yellow-500 via-yellow-400 to-yellow-600'
|
||||
: userResult.isPodium
|
||||
? 'bg-gradient-to-r from-gray-400 via-gray-300 to-gray-500'
|
||||
: 'bg-gradient-to-r from-primary-blue via-primary-blue/80 to-primary-blue'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="relative bg-deep-graphite rounded-xl p-6 sm:p-8">
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute top-0 left-0 w-32 h-32 bg-gradient-to-br from-white/10 to-transparent rounded-full blur-2xl" />
|
||||
<div className="absolute bottom-0 right-0 w-48 h-48 bg-gradient-to-tl from-white/5 to-transparent rounded-full blur-3xl" />
|
||||
|
||||
{/* Victory confetti effect for P1 */}
|
||||
{userResult.position === 1 && (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-4 left-[10%] w-2 h-2 bg-yellow-400 rounded-full animate-pulse" />
|
||||
<div className="absolute top-8 left-[25%] w-1.5 h-1.5 bg-yellow-300 rounded-full animate-pulse delay-100" />
|
||||
<div className="absolute top-6 right-[20%] w-2 h-2 bg-yellow-500 rounded-full animate-pulse delay-200" />
|
||||
<div className="absolute top-10 right-[35%] w-1 h-1 bg-yellow-400 rounded-full animate-pulse delay-300" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10">
|
||||
{/* Main content grid */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
|
||||
{/* Left: Position and achievement */}
|
||||
<div className="flex items-center gap-5">
|
||||
{/* Giant position badge */}
|
||||
<div
|
||||
className={`
|
||||
relative flex items-center justify-center w-24 h-24 sm:w-28 sm:h-28 rounded-3xl font-black text-4xl sm:text-5xl
|
||||
${
|
||||
userResult.position === 1
|
||||
? 'bg-gradient-to-br from-yellow-400 to-yellow-600 text-deep-graphite shadow-2xl shadow-yellow-500/30'
|
||||
: userResult.position === 2
|
||||
? 'bg-gradient-to-br from-gray-300 to-gray-500 text-deep-graphite shadow-xl shadow-gray-400/20'
|
||||
: userResult.position === 3
|
||||
? 'bg-gradient-to-br from-amber-600 to-amber-800 text-white shadow-xl shadow-amber-600/20'
|
||||
: 'bg-gradient-to-br from-primary-blue to-primary-blue/70 text-white shadow-xl shadow-primary-blue/20'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{userResult.position === 1 && (
|
||||
<Trophy className="absolute -top-3 -right-2 w-8 h-8 text-yellow-300 drop-shadow-lg" />
|
||||
)}
|
||||
<span>P{userResult.position}</span>
|
||||
</div>
|
||||
|
||||
{/* Achievement text */}
|
||||
<div>
|
||||
<p
|
||||
className={`
|
||||
text-2xl sm:text-3xl font-bold mb-1
|
||||
${
|
||||
userResult.position === 1
|
||||
? 'text-yellow-400'
|
||||
: userResult.isPodium
|
||||
? 'text-gray-300'
|
||||
: 'text-white'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{userResult.position === 1
|
||||
? '🏆 VICTORY!'
|
||||
: userResult.position === 2
|
||||
? '🥈 Second Place'
|
||||
: userResult.position === 3
|
||||
? '🥉 Podium Finish'
|
||||
: userResult.position <= 5
|
||||
? '⭐ Top 5 Finish'
|
||||
: userResult.position <= 10
|
||||
? 'Points Finish'
|
||||
: `P${userResult.position} Finish`}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 text-sm text-gray-400">
|
||||
<span>Started P{userResult.startPosition}</span>
|
||||
<span className="w-1 h-1 rounded-full bg-gray-600" />
|
||||
<span className={userResult.isClean ? 'text-performance-green' : ''}>
|
||||
{userResult.incidents}x incidents
|
||||
{userResult.isClean && ' ✨'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Stats cards */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* Position change */}
|
||||
{userResult.positionChange !== 0 && (
|
||||
<div
|
||||
className={`
|
||||
flex flex-col items-center px-5 py-3 rounded-2xl min-w-[100px]
|
||||
${
|
||||
userResult.positionChange > 0
|
||||
? 'bg-gradient-to-br from-performance-green/30 to-performance-green/10 border border-performance-green/40'
|
||||
: 'bg-gradient-to-br from-red-500/30 to-red-500/10 border border-red-500/40'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-1 font-black text-2xl
|
||||
${
|
||||
userResult.positionChange > 0
|
||||
? 'text-performance-green'
|
||||
: 'text-red-400'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{userResult.positionChange > 0 ? (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 17a.75.75 0 01-.75-.75V5.612L5.29 9.77a.75.75 0 01-1.08-1.04l5.25-5.5a.75.75 0 011.08 0l5.25 5.5a.75.75 0 11-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0110 17z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{Math.abs(userResult.positionChange)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">
|
||||
{userResult.positionChange > 0 ? 'Gained' : 'Lost'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rating change */}
|
||||
{ratingChange !== null && (
|
||||
<div
|
||||
className={`
|
||||
flex flex-col items-center px-5 py-3 rounded-2xl min-w-[100px]
|
||||
${
|
||||
ratingChange > 0
|
||||
? 'bg-gradient-to-br from-warning-amber/30 to-warning-amber/10 border border-warning-amber/40'
|
||||
: 'bg-gradient-to-br from-red-500/30 to-red-500/10 border border-red-500/40'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
font-mono font-black text-2xl
|
||||
${ratingChange > 0 ? 'text-warning-amber' : 'text-red-400'}
|
||||
`}
|
||||
>
|
||||
{animatedRatingChange > 0 ? '+' : ''}
|
||||
{animatedRatingChange}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">Rating</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clean race bonus */}
|
||||
{userResult.isClean && (
|
||||
<div className="flex flex-col items-center px-5 py-3 rounded-2xl min-w-[100px] bg-gradient-to-br from-performance-green/30 to-performance-green/10 border border-performance-green/40">
|
||||
<div className="text-2xl">✨</div>
|
||||
<div className="text-xs text-performance-green mt-0.5 font-medium">
|
||||
Clean Race
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<RaceUserResult
|
||||
{...userResult}
|
||||
animatedRatingChange={animatedRatingChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hero Header */}
|
||||
<div className={`relative overflow-hidden rounded-2xl ${config.bg} border ${config.border} p-6 sm:p-8`}>
|
||||
{/* Live indicator */}
|
||||
{race.status === 'running' && (
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-performance-green via-performance-green/50 to-performance-green animate-pulse" />
|
||||
)}
|
||||
<RaceHero
|
||||
track={race.track}
|
||||
scheduledAt={race.scheduledAt}
|
||||
car={race.car}
|
||||
status={race.status}
|
||||
statusConfig={config}
|
||||
/>
|
||||
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full blur-3xl" />
|
||||
<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'}
|
||||
/>
|
||||
|
||||
<div className="relative z-10">
|
||||
{/* Status Badge */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full ${config.bg} border ${config.border}`}>
|
||||
{race.status === 'running' && (
|
||||
<span className="w-2 h-2 bg-performance-green rounded-full animate-pulse" />
|
||||
)}
|
||||
<StatusIcon className={`w-4 h-4 ${config.color}`} />
|
||||
<span className={`text-sm font-semibold ${config.color}`}>{config.label}</span>
|
||||
</div>
|
||||
{timeUntil && (
|
||||
<span className="text-sm text-gray-400">
|
||||
Starts in <span className="text-white font-medium">{timeUntil}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<RaceEntryList
|
||||
entries={entryList}
|
||||
onDriverClick={onDriverClick}
|
||||
/>
|
||||
</Stack>
|
||||
</GridItem>
|
||||
|
||||
{/* Title */}
|
||||
<Heading level={1} className="text-2xl sm:text-3xl font-bold text-white mb-2">
|
||||
{race.track}
|
||||
</Heading>
|
||||
<GridItem lgSpan={4} colSpan={12}>
|
||||
<Stack gap={6}>
|
||||
{league && <LeagueSummaryCard league={league} />}
|
||||
|
||||
{/* Meta */}
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-gray-400">
|
||||
<span className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{formatDate(new Date(race.scheduledAt))}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatTime(new Date(race.scheduledAt))}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Car className="w-4 h-4" />
|
||||
{race.car}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Prominent SOF Badge - Electric Design */}
|
||||
{raceSOF != null && (
|
||||
<div className="absolute top-6 right-6 sm:top-8 sm:right-8">
|
||||
<div className="relative group">
|
||||
{/* Glow effect */}
|
||||
<div className="absolute inset-0 bg-warning-amber/40 rounded-2xl blur-xl group-hover:blur-2xl transition-all duration-300" />
|
||||
|
||||
<div className="relative flex items-center gap-4 px-6 py-4 rounded-2xl bg-gradient-to-br from-warning-amber/30 via-warning-amber/20 to-orange-500/20 border border-warning-amber/50 shadow-2xl backdrop-blur-sm">
|
||||
{/* Electric bolt with animation */}
|
||||
<div className="relative">
|
||||
<Zap className="w-8 h-8 text-warning-amber drop-shadow-lg" />
|
||||
<Zap className="absolute inset-0 w-8 h-8 text-warning-amber animate-pulse opacity-50" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] text-warning-amber/90 uppercase tracking-widest font-bold mb-0.5">
|
||||
Strength of Field
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-3xl font-black text-warning-amber font-mono tracking-tight drop-shadow-lg">
|
||||
{raceSOF}
|
||||
</span>
|
||||
<span className="text-sm text-warning-amber/70 font-medium">SOF</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Race Details */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Flag className="w-5 h-5 text-primary-blue" />
|
||||
Race Details
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-deep-graphite rounded-lg">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Track</p>
|
||||
<p className="text-white font-medium">{race.track}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-deep-graphite rounded-lg">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Car</p>
|
||||
<p className="text-white font-medium">{race.car}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-deep-graphite rounded-lg">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Session Type</p>
|
||||
<p className="text-white font-medium capitalize">{race.sessionType}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-deep-graphite rounded-lg">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Status</p>
|
||||
<p className={`font-medium ${config.color}`}>{config.label}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-deep-graphite rounded-lg">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Strength of Field</p>
|
||||
<p className="text-warning-amber font-medium flex items-center gap-1.5">
|
||||
<Zap className="w-4 h-4" />
|
||||
{raceSOF ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Entry List */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-primary-blue" />
|
||||
Entry List
|
||||
</h2>
|
||||
<span className="text-sm text-gray-400">
|
||||
{entryList.length} driver{entryList.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{entryList.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="p-4 bg-iron-gray rounded-full inline-block mb-3">
|
||||
<Users className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
<p className="text-gray-400">No drivers registered yet</p>
|
||||
<p className="text-sm text-gray-500">Be the first to sign up!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{entryList.map((driver, index) => {
|
||||
const isCurrentUser = driver.isCurrentUser;
|
||||
const countryFlag = getCountryFlag(driver.country);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={driver.id}
|
||||
onClick={() => onDriverClick(driver.id)}
|
||||
className={`
|
||||
flex items-center gap-3 p-3 rounded-xl cursor-pointer transition-all duration-200
|
||||
${
|
||||
isCurrentUser
|
||||
? 'bg-gradient-to-r from-primary-blue/20 via-primary-blue/10 to-transparent border border-primary-blue/40 shadow-lg shadow-primary-blue/10'
|
||||
: 'bg-deep-graphite hover:bg-charcoal-outline/50 border border-transparent'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Position number */}
|
||||
<div
|
||||
className={`
|
||||
flex items-center justify-center w-8 h-8 rounded-lg font-bold text-sm
|
||||
${
|
||||
race.status === 'completed' && index === 0
|
||||
? 'bg-yellow-500/20 text-yellow-400'
|
||||
: race.status === 'completed' && index === 1
|
||||
? 'bg-gray-400/20 text-gray-300'
|
||||
: race.status === 'completed' && index === 2
|
||||
? 'bg-amber-600/20 text-amber-500'
|
||||
: 'bg-iron-gray text-gray-500'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Avatar with nation flag */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<img
|
||||
src={driver.avatarUrl}
|
||||
alt={driver.name}
|
||||
className={`
|
||||
w-10 h-10 rounded-full object-cover
|
||||
${isCurrentUser ? 'ring-2 ring-primary-blue/50' : ''}
|
||||
`}
|
||||
/>
|
||||
{/* Nation flag */}
|
||||
<div className="absolute -bottom-0.5 -right-0.5 w-5 h-5 rounded-full bg-deep-graphite border-2 border-deep-graphite flex items-center justify-center text-xs shadow-sm">
|
||||
{countryFlag}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Driver info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={`text-sm font-semibold truncate ${
|
||||
isCurrentUser ? 'text-primary-blue' : 'text-white'
|
||||
}`}
|
||||
>
|
||||
{driver.name}
|
||||
</p>
|
||||
{isCurrentUser && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-bold bg-primary-blue text-white rounded-full uppercase tracking-wide">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{driver.country}</p>
|
||||
</div>
|
||||
|
||||
{/* Rating badge */}
|
||||
{driver.rating != null && (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-warning-amber/10 border border-warning-amber/20">
|
||||
<Zap className="w-3 h-3 text-warning-amber" />
|
||||
<span className="text-xs font-bold text-warning-amber font-mono">
|
||||
{driver.rating}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* League Card - Premium Design */}
|
||||
{league && (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-14 h-14 rounded-xl overflow-hidden bg-iron-gray flex-shrink-0">
|
||||
<img
|
||||
src={`league-logo-${league.id}`}
|
||||
alt={league.name}
|
||||
className="w-full h-full object-cover"
|
||||
{/* 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}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-0.5">League</p>
|
||||
<h3 className="text-white font-semibold truncate">{league.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{league.description && (
|
||||
<p className="text-sm text-gray-400 mb-4 line-clamp-2">{league.description}</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
<div className="p-3 rounded-lg bg-deep-graphite">
|
||||
<p className="text-xs text-gray-500 mb-1">Max Drivers</p>
|
||||
<p className="text-white font-medium">{league.settings.maxDrivers ?? 32}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-deep-graphite">
|
||||
<p className="text-xs text-gray-500 mb-1">Format</p>
|
||||
<p className="text-white font-medium capitalize">
|
||||
{league.settings.qualifyingFormat ?? 'Open'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/leagues/${league.id}`}
|
||||
className="flex items-center justify-center gap-2 w-full py-2.5 rounded-lg bg-primary-blue/10 border border-primary-blue/30 text-primary-blue text-sm font-medium hover:bg-primary-blue/20 transition-colors"
|
||||
>
|
||||
View League
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick Actions Card */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Actions</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Registration Actions */}
|
||||
<RaceJoinButton
|
||||
raceStatus={race.status}
|
||||
isUserRegistered={viewModel.registration.isUserRegistered}
|
||||
canRegister={viewModel.registration.canRegister}
|
||||
onRegister={onRegister}
|
||||
onWithdraw={onWithdraw}
|
||||
onCancel={onCancel}
|
||||
onReopen={onReopen}
|
||||
onEndRace={onEndRace}
|
||||
canReopenRace={viewModel.canReopenRace}
|
||||
isOwnerOrAdmin={isOwnerOrAdmin}
|
||||
isLoading={mutationLoading}
|
||||
/>
|
||||
|
||||
{/* Results and Stewarding for completed races */}
|
||||
{race.status === 'completed' && (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={onResultsClick}
|
||||
>
|
||||
<Trophy className="w-4 h-4" />
|
||||
View Results
|
||||
</Button>
|
||||
{userResult && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={onFileProtest}
|
||||
>
|
||||
<Scale className="w-4 h-4" />
|
||||
File Protest
|
||||
</Button>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={onStewardingClick}
|
||||
>
|
||||
<Scale className="w-4 h-4" />
|
||||
Stewarding
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Status Info */}
|
||||
<Card className={`${config.bg} border ${config.border}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg ${config.bg}`}>
|
||||
<StatusIcon className={`w-5 h-5 ${config.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-medium ${config.color}`}>{config.label}</p>
|
||||
<p className="text-sm text-gray-400 mt-1">{config.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals would be rendered by parent */}
|
||||
</div>
|
||||
{/* Status Info */}
|
||||
<InfoBox
|
||||
icon={config.icon}
|
||||
title={config.label}
|
||||
description={config.description}
|
||||
variant={config.variant}
|
||||
/>
|
||||
</Stack>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { ArrowLeft, Calendar, Trophy, Users, Zap } from 'lucide-react';
|
||||
|
||||
export interface ResultEntry {
|
||||
position: number;
|
||||
driverId: string;
|
||||
driverName: string;
|
||||
driverAvatar: string;
|
||||
country: string;
|
||||
car: string;
|
||||
laps: number;
|
||||
time: string;
|
||||
fastestLap: string;
|
||||
points: number;
|
||||
incidents: number;
|
||||
isCurrentUser: boolean;
|
||||
}
|
||||
|
||||
export interface PenaltyEntry {
|
||||
driverId: string;
|
||||
driverName: string;
|
||||
type: 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points';
|
||||
value: number;
|
||||
reason: string;
|
||||
notes?: string;
|
||||
}
|
||||
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 { Grid } from '@/ui/Grid';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { ArrowLeft, Trophy, Zap } from 'lucide-react';
|
||||
import type { RaceResultsViewData } from '@/lib/view-data/races/RaceResultsViewData';
|
||||
import { RaceResultRow } from '@/components/races/RaceResultRow';
|
||||
import { RacePenaltyRow } from '@/components/races/RacePenaltyRow';
|
||||
|
||||
export interface RaceResultsTemplateProps {
|
||||
raceTrack?: string;
|
||||
raceScheduledAt?: string;
|
||||
totalDrivers?: number;
|
||||
leagueName?: string;
|
||||
raceSOF?: number | null;
|
||||
results: ResultEntry[];
|
||||
penalties: PenaltyEntry[];
|
||||
pointsSystem: Record<string, number>;
|
||||
fastestLapTime: number;
|
||||
viewData: RaceResultsViewData;
|
||||
currentDriverId: string;
|
||||
isAdmin: boolean;
|
||||
isLoading: boolean;
|
||||
@@ -56,27 +36,15 @@ export interface RaceResultsTemplateProps {
|
||||
}
|
||||
|
||||
export function RaceResultsTemplate({
|
||||
raceTrack,
|
||||
raceScheduledAt,
|
||||
totalDrivers,
|
||||
leagueName,
|
||||
raceSOF,
|
||||
results,
|
||||
penalties,
|
||||
pointsSystem,
|
||||
fastestLapTime,
|
||||
viewData,
|
||||
currentDriverId,
|
||||
isAdmin,
|
||||
isLoading,
|
||||
error,
|
||||
onBack,
|
||||
onImportResults,
|
||||
onPenaltyClick,
|
||||
importing,
|
||||
importSuccess,
|
||||
importError,
|
||||
showImportForm,
|
||||
setShowImportForm,
|
||||
}: RaceResultsTemplateProps) {
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
@@ -94,270 +62,167 @@ export function RaceResultsTemplate({
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const getCountryFlag = (countryCode: string): string => {
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(char => 127397 + char.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
};
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: 'Races', href: '/races' },
|
||||
...(leagueName ? [{ label: leagueName, href: `/leagues/${leagueName}` }] : []),
|
||||
...(raceTrack ? [{ label: raceTrack, href: `/races/${raceTrack}` }] : []),
|
||||
...(viewData.leagueName ? [{ label: viewData.leagueName, href: `/leagues/${viewData.leagueName}` }] : []),
|
||||
...(viewData.raceTrack ? [{ label: viewData.raceTrack, href: `/races/${viewData.raceTrack}` }] : []),
|
||||
{ label: 'Results' },
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center text-gray-400">Loading results...</div>
|
||||
</div>
|
||||
</div>
|
||||
<Container size="lg" py={12}>
|
||||
<Stack align="center">
|
||||
<Text color="text-gray-400">Loading results...</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !raceTrack) {
|
||||
if (error && !viewData.raceTrack) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<Card className="text-center py-12">
|
||||
<div className="text-warning-amber mb-4">
|
||||
{error?.message || 'Race not found'}
|
||||
</div>
|
||||
<Container size="md" py={12}>
|
||||
<Card>
|
||||
<Stack align="center" py={12} gap={4}>
|
||||
<Text color="text-warning-amber">{error?.message || 'Race not found'}</Text>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
>
|
||||
Back to Races
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const hasResults = results.length > 0;
|
||||
const hasResults = viewData.results.length > 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Breadcrumbs items={breadcrumbItems} className="text-sm text-gray-400" />
|
||||
<Container size="lg" py={8}>
|
||||
<Stack gap={6}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Breadcrumbs items={breadcrumbItems} />
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
icon={<Icon icon={ArrowLeft} size={4} />}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{/* Header */}
|
||||
<Card className="bg-gradient-to-r from-iron-gray/50 to-iron-gray/30">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary-blue/20 flex items-center justify-center">
|
||||
<Trophy className="w-6 h-6 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Race Results</h1>
|
||||
<p className="text-sm text-gray-400">
|
||||
{raceTrack} • {raceScheduledAt ? formatDate(raceScheduledAt) : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Surface variant="muted" rounded="xl" border padding={6} style={{ background: 'linear-gradient(to right, rgba(38, 38, 38, 0.5), rgba(38, 38, 38, 0.3))', borderColor: '#262626' }}>
|
||||
<Stack direction="row" align="center" gap={4} mb={6}>
|
||||
<Surface variant="muted" rounded="xl" padding={3} style={{ backgroundColor: 'rgba(59, 130, 246, 0.2)' }}>
|
||||
<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>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="p-3 bg-deep-graphite/60 rounded-lg">
|
||||
<p className="text-xs text-gray-400 mb-1">Drivers</p>
|
||||
<p className="text-lg font-bold text-white">{totalDrivers ?? 0}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-deep-graphite/60 rounded-lg">
|
||||
<p className="text-xs text-gray-400 mb-1">League</p>
|
||||
<p className="text-sm font-medium text-white truncate">{leagueName ?? '—'}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-deep-graphite/60 rounded-lg">
|
||||
<p className="text-xs text-gray-400 mb-1">SOF</p>
|
||||
<p className="text-lg font-bold text-warning-amber flex items-center gap-1">
|
||||
<Zap className="w-4 h-4" />
|
||||
{raceSOF ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-deep-graphite/60 rounded-lg">
|
||||
<p className="text-xs text-gray-400 mb-1">Fastest Lap</p>
|
||||
<p className="text-lg font-bold text-performance-green">
|
||||
{fastestLapTime ? formatTime(fastestLapTime) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<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="#f59e0b" />
|
||||
<StatItem label="Fastest Lap" value={viewData.fastestLapTime ? formatTime(viewData.fastestLapTime) : '—'} color="#10b981" />
|
||||
</Grid>
|
||||
</Surface>
|
||||
|
||||
{importSuccess && (
|
||||
<div className="p-4 bg-performance-green/10 border border-performance-green/30 rounded-lg text-performance-green">
|
||||
<strong>Success!</strong> Results imported and standings updated.
|
||||
</div>
|
||||
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)', borderColor: 'rgba(16, 185, 129, 0.3)' }}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{importError && (
|
||||
<div className="p-4 bg-warning-amber/10 border border-warning-amber/30 rounded-lg text-warning-amber">
|
||||
<strong>Error:</strong> {importError}
|
||||
</div>
|
||||
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', borderColor: 'rgba(239, 68, 68, 0.3)' }}>
|
||||
<Text color="text-error-red" weight="bold">Error:</Text>
|
||||
<Text color="text-error-red" size="sm" block mt={1}>{importError}</Text>
|
||||
</Surface>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
{hasResults ? (
|
||||
<div className="space-y-4">
|
||||
<Stack gap={6}>
|
||||
{/* Results Table */}
|
||||
<div className="space-y-2">
|
||||
{results.map((result) => {
|
||||
const isCurrentUser = result.driverId === currentDriverId;
|
||||
const countryFlag = getCountryFlag(result.country);
|
||||
const points = pointsSystem[result.position.toString()] ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.driverId}
|
||||
className={`
|
||||
flex items-center gap-3 p-3 rounded-xl
|
||||
${isCurrentUser ? 'bg-gradient-to-r from-primary-blue/20 via-primary-blue/10 to-transparent border border-primary-blue/40' : 'bg-deep-graphite'}
|
||||
`}
|
||||
>
|
||||
{/* Position */}
|
||||
<div className={`
|
||||
flex items-center justify-center w-10 h-10 rounded-lg font-bold
|
||||
${result.position === 1 ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
result.position === 2 ? 'bg-gray-400/20 text-gray-300' :
|
||||
result.position === 3 ? 'bg-amber-600/20 text-amber-500' :
|
||||
'bg-iron-gray text-gray-500'}
|
||||
`}>
|
||||
{result.position}
|
||||
</div>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<img
|
||||
src={result.driverAvatar}
|
||||
alt={result.driverName}
|
||||
className={`w-10 h-10 rounded-full object-cover ${isCurrentUser ? 'ring-2 ring-primary-blue/50' : ''}`}
|
||||
/>
|
||||
<div className="absolute -bottom-0.5 -right-0.5 w-5 h-5 rounded-full bg-deep-graphite border-2 border-deep-graphite flex items-center justify-center text-xs shadow-sm">
|
||||
{countryFlag}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Driver Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`text-sm font-semibold truncate ${isCurrentUser ? 'text-primary-blue' : 'text-white'}`}>
|
||||
{result.driverName}
|
||||
</p>
|
||||
{isCurrentUser && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-bold bg-primary-blue text-white rounded-full uppercase tracking-wide">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400 mt-0.5">
|
||||
<span>{result.car}</span>
|
||||
<span>•</span>
|
||||
<span>Laps: {result.laps}</span>
|
||||
<span>•</span>
|
||||
<span>Incidents: {result.incidents}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Times */}
|
||||
<div className="text-right min-w-[100px]">
|
||||
<p className="text-sm font-mono text-white">{result.time}</p>
|
||||
<p className="text-xs text-performance-green">FL: {result.fastestLap}</p>
|
||||
</div>
|
||||
|
||||
{/* Points */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex flex-col items-center px-3 py-1 rounded-lg bg-warning-amber/10 border border-warning-amber/20">
|
||||
<span className="text-xs text-gray-400">PTS</span>
|
||||
<span className="text-sm font-bold text-warning-amber">{points}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Stack gap={2}>
|
||||
{viewData.results.map((result) => (
|
||||
<RaceResultRow
|
||||
key={result.driverId}
|
||||
result={result as any}
|
||||
points={viewData.pointsSystem[result.position.toString()] ?? 0}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Penalties Section */}
|
||||
{penalties.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-charcoal-outline">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Penalties</h3>
|
||||
<div className="space-y-2">
|
||||
{penalties.map((penalty, index) => (
|
||||
<div key={index} className="flex items-center gap-3 p-3 bg-deep-graphite rounded-lg">
|
||||
<div className="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-red-400 font-bold text-sm">!</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-white">{penalty.driverName}</span>
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
|
||||
{penalty.type.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{penalty.reason}</p>
|
||||
{penalty.notes && (
|
||||
<p className="text-sm text-gray-500 mt-1 italic">{penalty.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-2xl font-bold text-red-400">
|
||||
{penalty.type === 'time_penalty' && `+${penalty.value}s`}
|
||||
{penalty.type === 'grid_penalty' && `+${penalty.value} grid`}
|
||||
{penalty.type === 'points_deduction' && `-${penalty.value} pts`}
|
||||
{penalty.type === 'disqualification' && 'DSQ'}
|
||||
{penalty.type === 'warning' && 'Warning'}
|
||||
{penalty.type === 'license_points' && `${penalty.value} LP`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{viewData.penalties.length > 0 && (
|
||||
<Box pt={6} style={{ borderTop: '1px solid #262626' }}>
|
||||
<Box mb={4}>
|
||||
<Heading level={2}>Penalties</Heading>
|
||||
</Box>
|
||||
<Stack gap={2}>
|
||||
{viewData.penalties.map((penalty, index) => (
|
||||
<RacePenaltyRow key={index} penalty={penalty as any} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold text-white mb-6">Import Results</h2>
|
||||
<p className="text-gray-400 text-sm mb-6">
|
||||
No results imported. Upload CSV to test the standings system.
|
||||
</p>
|
||||
<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 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
Importing results and updating standings...
|
||||
</div>
|
||||
<Stack align="center" py={8}>
|
||||
<Text color="text-gray-400">Importing results and updating standings...</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
<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.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
// Mock import for demo
|
||||
onImportResults([]);
|
||||
}}
|
||||
disabled={importing}
|
||||
>
|
||||
Import Results (Demo)
|
||||
</Button>
|
||||
</div>
|
||||
</Text>
|
||||
<Box>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => onImportResults([])}
|
||||
disabled={importing}
|
||||
>
|
||||
Import Results (Demo)
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function StatItem({ label, value, icon, color = 'text-white' }: { label: string, value: string | number, icon?: any, color?: string }) {
|
||||
return (
|
||||
<Surface variant="muted" rounded="lg" padding={3} style={{ backgroundColor: 'rgba(15, 17, 21, 0.6)' }}>
|
||||
<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 as any} style={{ fontSize: '1.125rem' }}>{value}</Text>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,74 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import React from 'react';
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import RaceStewardingStats from '@/components/races/RaceStewardingStats';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { StewardingTabs } from '@/components/races/StewardingTabs';
|
||||
import { ProtestCard } from '@/components/races/ProtestCard';
|
||||
import { RacePenaltyRow } from '@/components/races/RacePenaltyRow';
|
||||
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 {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Flag,
|
||||
Gavel,
|
||||
Scale,
|
||||
Video
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import type { RaceStewardingViewData } from '@/lib/view-data/races/RaceStewardingViewData';
|
||||
|
||||
export type StewardingTab = 'pending' | 'resolved' | 'penalties';
|
||||
|
||||
export interface Protest {
|
||||
id: string;
|
||||
status: string;
|
||||
protestingDriverId: string;
|
||||
accusedDriverId: string;
|
||||
filedAt: string;
|
||||
incident: {
|
||||
lap: number;
|
||||
description: string;
|
||||
};
|
||||
proofVideoUrl?: string;
|
||||
decisionNotes?: string;
|
||||
}
|
||||
|
||||
export interface Penalty {
|
||||
id: string;
|
||||
driverId: string;
|
||||
type: string;
|
||||
value: number;
|
||||
reason: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface Driver {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RaceStewardingData {
|
||||
race?: {
|
||||
id: string;
|
||||
track: string;
|
||||
scheduledAt: string;
|
||||
} | null;
|
||||
league?: {
|
||||
id: string;
|
||||
} | null;
|
||||
pendingProtests: Protest[];
|
||||
resolvedProtests: Protest[];
|
||||
penalties: Penalty[];
|
||||
driverMap: Record<string, Driver>;
|
||||
pendingCount: number;
|
||||
resolvedCount: number;
|
||||
penaltiesCount: number;
|
||||
}
|
||||
|
||||
export interface RaceStewardingTemplateProps {
|
||||
stewardingData?: RaceStewardingData;
|
||||
interface RaceStewardingTemplateProps {
|
||||
viewData: RaceStewardingViewData;
|
||||
isLoading: boolean;
|
||||
error?: Error | null;
|
||||
// Actions
|
||||
@@ -82,7 +42,7 @@ export interface RaceStewardingTemplateProps {
|
||||
}
|
||||
|
||||
export function RaceStewardingTemplate({
|
||||
stewardingData,
|
||||
viewData,
|
||||
isLoading,
|
||||
error,
|
||||
onBack,
|
||||
@@ -91,345 +51,178 @@ export function RaceStewardingTemplate({
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
}: RaceStewardingTemplateProps) {
|
||||
const formatDate = (date: Date | string) => {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('en-US', {
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
case 'under_review':
|
||||
return (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
|
||||
Pending
|
||||
</span>
|
||||
);
|
||||
case 'upheld':
|
||||
return (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
|
||||
Upheld
|
||||
</span>
|
||||
);
|
||||
case 'dismissed':
|
||||
return (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">
|
||||
Dismissed
|
||||
</span>
|
||||
);
|
||||
case 'withdrawn':
|
||||
return (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-blue-500/20 text-blue-400 rounded-full">
|
||||
Withdrawn
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-6 bg-iron-gray rounded w-1/4" />
|
||||
<div className="h-48 bg-iron-gray rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Container size="lg" py={12}>
|
||||
<Stack align="center">
|
||||
<Text color="text-gray-400">Loading stewarding data...</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stewardingData?.race) {
|
||||
if (!viewData?.race) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Card className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="p-4 bg-warning-amber/10 rounded-full">
|
||||
<AlertTriangle className="w-8 h-8 text-warning-amber" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium mb-1">Race not found</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
The race you're looking for doesn't exist.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={onBack}>
|
||||
Back to Races
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<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 style={{ 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>
|
||||
<Button variant="secondary" onClick={onBack}>
|
||||
Back to Races
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: 'Races', href: '/races' },
|
||||
{ label: stewardingData.race.track, href: `/races/${stewardingData.race.id}` },
|
||||
{ label: viewData.race.track, href: `/races/${viewData.race.id}` },
|
||||
{ label: 'Stewarding' },
|
||||
];
|
||||
|
||||
const pendingProtests = stewardingData.pendingProtests ?? [];
|
||||
const resolvedProtests = stewardingData.resolvedProtests ?? [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<Container size="lg" py={8}>
|
||||
<Stack gap={6}>
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Breadcrumbs items={breadcrumbItems} className="text-sm text-gray-400" />
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Breadcrumbs items={breadcrumbItems} />
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onBack()}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
onClick={onBack}
|
||||
icon={<Icon icon={ArrowLeft} size={4} />}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Race
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{/* Header */}
|
||||
<Card className="bg-gradient-to-r from-iron-gray/50 to-iron-gray/30">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary-blue/20 flex items-center justify-center">
|
||||
<Scale className="w-6 h-6 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Stewarding</h1>
|
||||
<p className="text-sm text-gray-400">
|
||||
{stewardingData.race.track} • {stewardingData.race.scheduledAt ? formatDate(stewardingData.race.scheduledAt) : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Surface variant="muted" rounded="xl" border padding={6} style={{ background: 'linear-gradient(to right, rgba(38, 38, 38, 0.5), rgba(38, 38, 38, 0.3))', borderColor: '#262626' }}>
|
||||
<Stack direction="row" align="center" gap={4} mb={6}>
|
||||
<Surface variant="muted" rounded="xl" padding={3} style={{ backgroundColor: 'rgba(59, 130, 246, 0.2)' }}>
|
||||
<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={stewardingData.pendingCount ?? 0}
|
||||
resolvedCount={stewardingData.resolvedCount ?? 0}
|
||||
penaltiesCount={stewardingData.penaltiesCount ?? 0}
|
||||
pendingCount={viewData.pendingCount}
|
||||
resolvedCount={viewData.resolvedCount}
|
||||
penaltiesCount={viewData.penaltiesCount}
|
||||
/>
|
||||
</Card>
|
||||
</Surface>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<StewardingTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
pendingCount={pendingProtests.length}
|
||||
pendingCount={viewData.pendingProtests.length}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === 'pending' && (
|
||||
<div className="space-y-4">
|
||||
{pendingProtests.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
|
||||
<Flag className="w-8 h-8 text-performance-green" />
|
||||
</div>
|
||||
<p className="font-semibold text-lg text-white mb-2">All Clear!</p>
|
||||
<p className="text-sm text-gray-400">No pending protests to review</p>
|
||||
<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 style={{ 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>
|
||||
) : (
|
||||
pendingProtests.map((protest) => {
|
||||
const protester = stewardingData.driverMap[protest.protestingDriverId];
|
||||
const accused = stewardingData.driverMap[protest.accusedDriverId];
|
||||
const daysSinceFiled = Math.floor(
|
||||
(Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const isUrgent = daysSinceFiled > 2;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={protest.id}
|
||||
className={`${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" />
|
||||
<Link
|
||||
href={`/drivers/${protest.protestingDriverId}`}
|
||||
className="font-medium text-white hover:text-primary-blue transition-colors"
|
||||
>
|
||||
{protester?.name || 'Unknown'}
|
||||
</Link>
|
||||
<span className="text-gray-400">vs</span>
|
||||
<Link
|
||||
href={`/drivers/${protest.accusedDriverId}`}
|
||||
className="font-medium text-white hover:text-primary-blue transition-colors"
|
||||
>
|
||||
{accused?.name || 'Unknown'}
|
||||
</Link>
|
||||
{getStatusBadge(protest.status)}
|
||||
{isUrgent && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{daysSinceFiled}d old
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
|
||||
<span>Lap {protest.incident.lap}</span>
|
||||
<span>•</span>
|
||||
<span>Filed {formatDate(protest.filedAt)}</span>
|
||||
{protest.proofVideoUrl && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<a
|
||||
href={protest.proofVideoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-primary-blue hover:underline"
|
||||
>
|
||||
<Video className="w-3 h-3" />
|
||||
Video Evidence
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">{protest.incident.description}</p>
|
||||
</div>
|
||||
{isAdmin && stewardingData?.league && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => onReviewProtest(protest.id)}
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
viewData.pendingProtests.map((protest) => (
|
||||
<ProtestCard
|
||||
key={protest.id}
|
||||
protest={protest as any}
|
||||
protester={viewData.driverMap[protest.protestingDriverId]}
|
||||
accused={viewData.driverMap[protest.accusedDriverId]}
|
||||
isAdmin={isAdmin}
|
||||
onReview={onReviewProtest}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{activeTab === 'resolved' && (
|
||||
<div className="space-y-4">
|
||||
{resolvedProtests.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-500/10 flex items-center justify-center">
|
||||
<CheckCircle className="w-8 h-8 text-gray-500" />
|
||||
</div>
|
||||
<p className="font-semibold text-lg text-white mb-2">No Resolved Protests</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Resolved protests will appear here
|
||||
</p>
|
||||
<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 style={{ 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>
|
||||
) : (
|
||||
resolvedProtests.map((protest) => {
|
||||
const protester = stewardingData.driverMap[protest.protestingDriverId];
|
||||
const accused = stewardingData.driverMap[protest.accusedDriverId];
|
||||
|
||||
return (
|
||||
<Card key={protest.id}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
<Link
|
||||
href={`/drivers/${protest.protestingDriverId}`}
|
||||
className="font-medium text-white hover:text-primary-blue transition-colors"
|
||||
>
|
||||
{protester?.name || 'Unknown'}
|
||||
</Link>
|
||||
<span className="text-gray-400">vs</span>
|
||||
<Link
|
||||
href={`/drivers/${protest.accusedDriverId}`}
|
||||
className="font-medium text-white hover:text-primary-blue transition-colors"
|
||||
>
|
||||
{accused?.name || 'Unknown'}
|
||||
</Link>
|
||||
{getStatusBadge(protest.status)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
|
||||
<span>Lap {protest.incident.lap}</span>
|
||||
<span>•</span>
|
||||
<span>Filed {formatDate(protest.filedAt)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300 mb-2">
|
||||
{protest.incident.description}
|
||||
</p>
|
||||
{protest.decisionNotes && (
|
||||
<div className="mt-2 p-3 rounded bg-iron-gray/50 border border-charcoal-outline/50">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">
|
||||
Steward Decision
|
||||
</p>
|
||||
<p className="text-sm text-gray-300">{protest.decisionNotes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
viewData.resolvedProtests.map((protest) => (
|
||||
<ProtestCard
|
||||
key={protest.id}
|
||||
protest={protest as any}
|
||||
protester={viewData.driverMap[protest.protestingDriverId]}
|
||||
accused={viewData.driverMap[protest.accusedDriverId]}
|
||||
isAdmin={isAdmin}
|
||||
onReview={onReviewProtest}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{activeTab === 'penalties' && (
|
||||
<div className="space-y-4">
|
||||
{stewardingData?.penalties.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-500/10 flex items-center justify-center">
|
||||
<Gavel className="w-8 h-8 text-gray-500" />
|
||||
</div>
|
||||
<p className="font-semibold text-lg text-white mb-2">No Penalties</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Penalties issued for this race will appear here
|
||||
</p>
|
||||
<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 style={{ 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>
|
||||
) : (
|
||||
stewardingData?.penalties.map((penalty) => {
|
||||
const driver = stewardingData.driverMap[penalty.driverId];
|
||||
return (
|
||||
<Card key={penalty.id}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Gavel className="w-6 h-6 text-red-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Link
|
||||
href={`/drivers/${penalty.driverId}`}
|
||||
className="font-medium text-white hover:text-primary-blue transition-colors"
|
||||
>
|
||||
{driver?.name || 'Unknown'}
|
||||
</Link>
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
|
||||
{penalty.type.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{penalty.reason}</p>
|
||||
{penalty.notes && (
|
||||
<p className="text-sm text-gray-500 mt-1 italic">{penalty.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-2xl font-bold text-red-400">
|
||||
{penalty.type === 'time_penalty' && `+${penalty.value}s`}
|
||||
{penalty.type === 'grid_penalty' && `+${penalty.value} grid`}
|
||||
{penalty.type === 'points_deduction' && `-${penalty.value} pts`}
|
||||
{penalty.type === 'disqualification' && 'DSQ'}
|
||||
{penalty.type === 'warning' && 'Warning'}
|
||||
{penalty.type === 'license_points' && `${penalty.value} LP`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
viewData.penalties.map((penalty) => (
|
||||
<RacePenaltyRow key={penalty.id} penalty={penalty as any} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import React, { useMemo, useEffect } from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
Flag,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Car,
|
||||
Trophy,
|
||||
Zap,
|
||||
PlayCircle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
Calendar,
|
||||
} from 'lucide-react';
|
||||
import { RaceFilterModal } from '@/components/races/RaceFilterModal';
|
||||
import { RacePagination } from '@/components/races/RacePagination';
|
||||
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 { Skeleton } from '@/ui/Skeleton';
|
||||
import { RaceListItem } from '@/components/races/RaceListItem';
|
||||
import type { RacesViewData } from '@/lib/view-data/RacesViewData';
|
||||
|
||||
export type StatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
|
||||
|
||||
export interface Race {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
sessionType: string;
|
||||
leagueId?: string;
|
||||
leagueName?: string;
|
||||
strengthOfField?: number | null;
|
||||
}
|
||||
|
||||
export interface RacesAllTemplateProps {
|
||||
races: Race[];
|
||||
interface RacesAllTemplateProps {
|
||||
viewData: RacesViewData;
|
||||
isLoading: boolean;
|
||||
// Pagination
|
||||
currentPage: number;
|
||||
@@ -64,7 +50,7 @@ export interface RacesAllTemplateProps {
|
||||
}
|
||||
|
||||
export function RacesAllTemplate({
|
||||
races,
|
||||
viewData,
|
||||
isLoading,
|
||||
currentPage,
|
||||
totalPages,
|
||||
@@ -81,8 +67,9 @@ export function RacesAllTemplate({
|
||||
showFilterModal,
|
||||
setShowFilterModal,
|
||||
onRaceClick,
|
||||
onLeagueClick,
|
||||
}: RacesAllTemplateProps) {
|
||||
const { races } = viewData;
|
||||
|
||||
// Filter races
|
||||
const filteredRaces = useMemo(() => {
|
||||
return races.filter(race => {
|
||||
@@ -119,55 +106,6 @@ export function RacesAllTemplate({
|
||||
onPageChange(1);
|
||||
}, [statusFilter, leagueFilter, searchQuery]);
|
||||
|
||||
const formatDate = (date: Date | string) => {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (date: Date | string) => {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
scheduled: {
|
||||
icon: Clock,
|
||||
color: 'text-primary-blue',
|
||||
bg: 'bg-primary-blue/10',
|
||||
border: 'border-primary-blue/30',
|
||||
label: 'Scheduled',
|
||||
},
|
||||
running: {
|
||||
icon: PlayCircle,
|
||||
color: 'text-performance-green',
|
||||
bg: 'bg-performance-green/10',
|
||||
border: 'border-performance-green/30',
|
||||
label: 'LIVE',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-gray-400',
|
||||
bg: 'bg-gray-500/10',
|
||||
border: 'border-gray-500/30',
|
||||
label: 'Completed',
|
||||
},
|
||||
cancelled: {
|
||||
icon: XCircle,
|
||||
color: 'text-warning-amber',
|
||||
bg: 'bg-warning-amber/10',
|
||||
border: 'border-warning-amber/30',
|
||||
label: 'Cancelled',
|
||||
},
|
||||
};
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: 'Races', href: '/races' },
|
||||
{ label: 'All Races' },
|
||||
@@ -175,214 +113,85 @@ export function RacesAllTemplate({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-6 bg-iron-gray rounded w-1/4" />
|
||||
<div className="h-10 bg-iron-gray rounded w-1/3" />
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="h-24 bg-iron-gray rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Container size="lg" py={8}>
|
||||
<Stack gap={6}>
|
||||
<Skeleton width="8rem" height="1.5rem" />
|
||||
<Skeleton width="12rem" height="2.5rem" />
|
||||
<Stack gap={4}>
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<Skeleton key={i} width="100%" height="6rem" />
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
<Container size="lg" py={8}>
|
||||
<Stack gap={6}>
|
||||
{/* Breadcrumbs */}
|
||||
<Breadcrumbs items={breadcrumbItems} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<Heading level={1} className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<Flag className="w-6 h-6 text-primary-blue" />
|
||||
<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>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>
|
||||
{filteredRaces.length} race{filteredRaces.length !== 1 ? 's' : ''} found
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center gap-2"
|
||||
icon={<Icon icon={SlidersHorizontal} size={4} />}
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
Filters
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{/* Search & Filters */}
|
||||
<Card className={`!p-4 ${showFilters ? '' : 'hidden sm:block'}`}>
|
||||
<div className="space-y-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search by track, car, or league..."
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter Row */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{/* Status Filter */}
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
|
||||
className="px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="running">Live</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
|
||||
{/* League Filter */}
|
||||
<select
|
||||
value={leagueFilter}
|
||||
onChange={(e) => setLeagueFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
>
|
||||
<option value="all">All Leagues</option>
|
||||
{races && [...new Set(races.map(r => r.leagueId))].filter(Boolean).map(leagueId => {
|
||||
const race = races.find(r => r.leagueId === leagueId);
|
||||
return race ? (
|
||||
<option key={leagueId} value={leagueId}>
|
||||
{race.leagueName}
|
||||
</option>
|
||||
) : null;
|
||||
})}
|
||||
</select>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{(statusFilter !== 'all' || leagueFilter !== 'all' || searchQuery) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setStatusFilter('all');
|
||||
setLeagueFilter('all');
|
||||
setSearchQuery('');
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-primary-blue hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/* 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 */}
|
||||
{paginatedRaces.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="p-4 bg-iron-gray rounded-full">
|
||||
<Calendar className="w-8 h-8 text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium mb-1">No races found</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
<Card>
|
||||
<Stack align="center" py={12} gap={4}>
|
||||
<Surface variant="muted" rounded="full" padding={4}>
|
||||
<Icon icon={Calendar} size={8} color="#525252" />
|
||||
</Surface>
|
||||
<Box style={{ textAlign: 'center' }}>
|
||||
<Text weight="medium" color="text-white" block mb={1}>No races found</Text>
|
||||
<Text size="sm" color="text-gray-500">
|
||||
{races.length === 0
|
||||
? 'No races have been scheduled yet'
|
||||
: 'Try adjusting your search or filters'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{paginatedRaces.map(race => {
|
||||
const config = statusConfig[race.status as keyof typeof statusConfig];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={race.id}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
className={`group relative overflow-hidden rounded-xl bg-iron-gray border ${config.border} p-4 cursor-pointer transition-all duration-200 hover:scale-[1.01] hover:border-primary-blue`}
|
||||
>
|
||||
{/* Live indicator */}
|
||||
{race.status === 'running' && (
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-performance-green via-performance-green/50 to-performance-green animate-pulse" />
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Date Column */}
|
||||
<div className="hidden sm:flex flex-col items-center min-w-[80px] text-center">
|
||||
<p className="text-xs text-gray-500 uppercase">
|
||||
{new Date(race.scheduledAt).toLocaleDateString('en-US', { month: 'short' })}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{new Date(race.scheduledAt).getDate()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatTime(race.scheduledAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="hidden sm:block w-px h-16 bg-charcoal-outline" />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-white truncate group-hover:text-primary-blue transition-colors">
|
||||
{race.track}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-1">
|
||||
<span className="flex items-center gap-1.5 text-sm text-gray-400">
|
||||
<Car className="w-3.5 h-3.5" />
|
||||
{race.car}
|
||||
</span>
|
||||
{race.strengthOfField && (
|
||||
<span className="flex items-center gap-1.5 text-sm text-warning-amber">
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
SOF {race.strengthOfField}
|
||||
</span>
|
||||
)}
|
||||
<span className="sm:hidden text-sm text-gray-500">
|
||||
{formatDate(race.scheduledAt)}
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href={`/leagues/${race.leagueId}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1.5 mt-2 text-sm text-primary-blue hover:underline"
|
||||
>
|
||||
<Trophy className="w-3.5 h-3.5" />
|
||||
{race.leagueName}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full ${config.bg} ${config.border} border flex-shrink-0`}>
|
||||
<StatusIcon className={`w-3.5 h-3.5 ${config.color}`} />
|
||||
<span className={`text-xs font-medium ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-primary-blue transition-colors flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Stack gap={3}>
|
||||
{paginatedRaces.map(race => (
|
||||
<RaceListItem key={race.id} race={race as any} onClick={onRaceClick} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
@@ -406,11 +215,11 @@ export function RacesAllTemplate({
|
||||
setTimeFilter={() => {}}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
leagues={[...new Set(races.map(r => ({ id: r.leagueId || '', name: r.leagueName || '' })))]}
|
||||
leagues={viewData.leagues}
|
||||
showSearch={true}
|
||||
showTimeFilter={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
Flag,
|
||||
ChevronRight,
|
||||
MapPin,
|
||||
Car,
|
||||
Trophy,
|
||||
Users,
|
||||
Zap,
|
||||
PlayCircle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
CalendarDays,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { RaceFilterModal } from '@/components/races/RaceFilterModal';
|
||||
import { RaceJoinButton } from '@/components/races/RaceJoinButton';
|
||||
import type { RacesViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { RacePageHeader } from '@/components/races/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 { Grid } from '@/ui/Grid';
|
||||
import { GridItem } from '@/ui/GridItem';
|
||||
|
||||
export type TimeFilter = 'all' | 'upcoming' | 'live' | 'past';
|
||||
export type RaceStatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
|
||||
|
||||
export interface Race {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
sessionType: string;
|
||||
leagueId?: string;
|
||||
leagueName?: string;
|
||||
strengthOfField?: number | null;
|
||||
isUpcoming: boolean;
|
||||
isLive: boolean;
|
||||
isPast: boolean;
|
||||
}
|
||||
|
||||
export interface RacesTemplateProps {
|
||||
races: Race[];
|
||||
totalCount: number;
|
||||
scheduledRaces: Race[];
|
||||
runningRaces: Race[];
|
||||
completedRaces: Race[];
|
||||
isLoading: boolean;
|
||||
viewData: RacesViewData;
|
||||
// Filters
|
||||
statusFilter: RaceStatusFilter;
|
||||
setStatusFilter: (filter: RaceStatusFilter) => void;
|
||||
@@ -58,24 +29,15 @@ export interface RacesTemplateProps {
|
||||
// Actions
|
||||
onRaceClick: (raceId: string) => void;
|
||||
onLeagueClick: (leagueId: string) => void;
|
||||
onRegister: (raceId: string, leagueId: string) => void;
|
||||
onWithdraw: (raceId: string) => void;
|
||||
onCancel: (raceId: string) => void;
|
||||
// UI State
|
||||
showFilterModal: boolean;
|
||||
setShowFilterModal: (show: boolean) => void;
|
||||
// User state
|
||||
currentDriverId?: string;
|
||||
userMemberships?: Array<{ leagueId: string; role: string }>;
|
||||
}
|
||||
|
||||
export function RacesTemplate({
|
||||
races,
|
||||
totalCount,
|
||||
scheduledRaces,
|
||||
runningRaces,
|
||||
completedRaces,
|
||||
isLoading,
|
||||
viewData,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
leagueFilter,
|
||||
@@ -83,581 +45,71 @@ export function RacesTemplate({
|
||||
timeFilter,
|
||||
setTimeFilter,
|
||||
onRaceClick,
|
||||
onLeagueClick,
|
||||
onRegister,
|
||||
onWithdraw,
|
||||
onCancel,
|
||||
showFilterModal,
|
||||
setShowFilterModal,
|
||||
currentDriverId,
|
||||
userMemberships,
|
||||
}: RacesTemplateProps) {
|
||||
// Filter races
|
||||
const filteredRaces = useMemo(() => {
|
||||
return races.filter((race) => {
|
||||
// Status filter
|
||||
if (statusFilter !== 'all' && race.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// League filter
|
||||
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Time filter
|
||||
if (timeFilter === 'upcoming' && !race.isUpcoming) {
|
||||
return false;
|
||||
}
|
||||
if (timeFilter === 'live' && !race.isLive) {
|
||||
return false;
|
||||
}
|
||||
if (timeFilter === 'past' && !race.isPast) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [races, statusFilter, leagueFilter, timeFilter]);
|
||||
|
||||
// Group races by date for calendar view
|
||||
const racesByDate = useMemo(() => {
|
||||
const grouped = new Map<string, typeof filteredRaces[0][]>();
|
||||
filteredRaces.forEach((race) => {
|
||||
const dateKey = race.scheduledAt.split('T')[0]!;
|
||||
if (!grouped.has(dateKey)) {
|
||||
grouped.set(dateKey, []);
|
||||
}
|
||||
grouped.get(dateKey)!.push(race);
|
||||
});
|
||||
return grouped;
|
||||
}, [filteredRaces]);
|
||||
|
||||
const upcomingRaces = filteredRaces.filter(r => r.isUpcoming).slice(0, 5);
|
||||
const liveRaces = filteredRaces.filter(r => r.isLive);
|
||||
const recentResults = filteredRaces.filter(r => r.isPast).slice(0, 5);
|
||||
const stats = {
|
||||
total: totalCount,
|
||||
scheduled: scheduledRaces.length,
|
||||
running: runningRaces.length,
|
||||
completed: completedRaces.length,
|
||||
};
|
||||
|
||||
const formatDate = (date: Date | string) => {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (date: Date | string) => {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatFullDate = (date: Date | string) => {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const getRelativeTime = (date?: Date | string) => {
|
||||
if (!date) return '';
|
||||
const now = new Date();
|
||||
const targetDate = typeof date === 'string' ? new Date(date) : date;
|
||||
const diffMs = targetDate.getTime() - now.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMs < 0) return 'Past';
|
||||
if (diffHours < 1) return 'Starting soon';
|
||||
if (diffHours < 24) return `In ${diffHours}h`;
|
||||
if (diffDays === 1) return 'Tomorrow';
|
||||
if (diffDays < 7) return `In ${diffDays} days`;
|
||||
return formatDate(targetDate);
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
scheduled: {
|
||||
icon: Clock,
|
||||
color: 'text-primary-blue',
|
||||
bg: 'bg-primary-blue/10',
|
||||
border: 'border-primary-blue/30',
|
||||
label: 'Scheduled',
|
||||
},
|
||||
running: {
|
||||
icon: PlayCircle,
|
||||
color: 'text-performance-green',
|
||||
bg: 'bg-performance-green/10',
|
||||
border: 'border-performance-green/30',
|
||||
label: 'LIVE',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-gray-400',
|
||||
bg: 'bg-gray-500/10',
|
||||
border: 'border-gray-500/30',
|
||||
label: 'Completed',
|
||||
},
|
||||
cancelled: {
|
||||
icon: XCircle,
|
||||
color: 'text-warning-amber',
|
||||
bg: 'bg-warning-amber/10',
|
||||
border: 'border-warning-amber/30',
|
||||
label: 'Cancelled',
|
||||
},
|
||||
};
|
||||
|
||||
const isUserRegistered = (race: Race) => {
|
||||
// This would need actual registration data
|
||||
return false;
|
||||
};
|
||||
|
||||
const canRegister = (race: Race) => {
|
||||
// This would need actual registration rules
|
||||
return race.status === 'scheduled';
|
||||
};
|
||||
|
||||
const isOwnerOrAdmin = (leagueId?: string) => {
|
||||
if (!leagueId || !userMemberships) return false;
|
||||
const membership = userMemberships.find(m => m.leagueId === leagueId);
|
||||
return membership?.role === 'owner' || membership?.role === 'admin';
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-10 bg-iron-gray rounded w-1/4" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="h-24 bg-iron-gray rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
<div className="h-64 bg-iron-gray rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
{/* Hero Header */}
|
||||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-iron-gray via-iron-gray to-charcoal-outline border border-charcoal-outline p-8">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-primary-blue/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 left-0 w-48 h-48 bg-performance-green/5 rounded-full blur-3xl" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-primary-blue/10 rounded-lg">
|
||||
<Flag className="w-6 h-6 text-primary-blue" />
|
||||
</div>
|
||||
<Heading level={1} className="text-3xl font-bold text-white">
|
||||
Race Calendar
|
||||
</Heading>
|
||||
</div>
|
||||
<p className="text-gray-400 max-w-2xl">
|
||||
Track upcoming races, view live events, and explore results across all your leagues.
|
||||
</p>
|
||||
</div>
|
||||
<Box as="main">
|
||||
<Container size="lg" py={8}>
|
||||
<Stack gap={8}>
|
||||
<RacePageHeader
|
||||
totalCount={viewData.totalCount}
|
||||
scheduledCount={viewData.scheduledCount}
|
||||
runningCount={viewData.runningCount}
|
||||
completedCount={viewData.completedCount}
|
||||
/>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="relative z-10 grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||||
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1">
|
||||
<CalendarDays className="w-4 h-4" />
|
||||
<span>Total</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{stats.total}</p>
|
||||
</div>
|
||||
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
|
||||
<div className="flex items-center gap-2 text-primary-blue text-sm mb-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>Scheduled</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{stats.scheduled}</p>
|
||||
</div>
|
||||
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
|
||||
<div className="flex items-center gap-2 text-performance-green text-sm mb-1">
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>Live Now</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{stats.running}</p>
|
||||
</div>
|
||||
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1">
|
||||
<Trophy className="w-4 h-4" />
|
||||
<span>Completed</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{stats.completed}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LiveRacesBanner
|
||||
liveRaces={viewData.liveRaces}
|
||||
onRaceClick={onRaceClick}
|
||||
/>
|
||||
|
||||
{/* Live Races Banner */}
|
||||
{liveRaces.length > 0 && (
|
||||
<div className="relative overflow-hidden rounded-xl bg-gradient-to-r from-performance-green/20 via-performance-green/10 to-transparent border border-performance-green/30 p-6">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-performance-green/20 rounded-full blur-2xl animate-pulse" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-performance-green/20 rounded-full">
|
||||
<span className="w-2 h-2 bg-performance-green rounded-full animate-pulse" />
|
||||
<span className="text-performance-green font-semibold text-sm">LIVE NOW</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{liveRaces.map((race) => (
|
||||
<div
|
||||
key={race.id}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
className="flex items-center justify-between p-4 bg-deep-graphite/80 rounded-lg border border-performance-green/20 cursor-pointer hover:border-performance-green/40 transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-performance-green/20 rounded-lg">
|
||||
<PlayCircle className="w-5 h-5 text-performance-green" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">{race.track}</h3>
|
||||
<p className="text-sm text-gray-400">{race.leagueName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Grid cols={12} gap={6}>
|
||||
<GridItem colSpan={12} lgSpan={8}>
|
||||
<Stack gap={6}>
|
||||
<RaceFilterBar
|
||||
timeFilter={timeFilter}
|
||||
setTimeFilter={setTimeFilter}
|
||||
leagueFilter={leagueFilter}
|
||||
setLeagueFilter={setLeagueFilter}
|
||||
leagues={viewData.leagues}
|
||||
onShowMoreFilters={() => setShowFilterModal(true)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Content - Race List */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Filters */}
|
||||
<Card className="!p-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{/* Time Filter Tabs */}
|
||||
<div className="flex items-center gap-1 p-1 bg-deep-graphite rounded-lg">
|
||||
{(['upcoming', 'live', 'past', 'all'] as TimeFilter[]).map(filter => (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setTimeFilter(filter)}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
timeFilter === filter
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{filter === 'live' && <span className="inline-block w-2 h-2 bg-performance-green rounded-full mr-2 animate-pulse" />}
|
||||
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<RaceList
|
||||
racesByDate={viewData.racesByDate}
|
||||
totalCount={viewData.totalCount}
|
||||
onRaceClick={onRaceClick}
|
||||
/>
|
||||
</Stack>
|
||||
</GridItem>
|
||||
|
||||
{/* League Filter */}
|
||||
<select
|
||||
value={leagueFilter}
|
||||
onChange={(e) => setLeagueFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
>
|
||||
<option value="all">All Leagues</option>
|
||||
{races && [...new Set(races.map(r => r.leagueId))].filter(Boolean).map(leagueId => {
|
||||
const item = races.find(r => r.leagueId === leagueId);
|
||||
return item ? (
|
||||
<option key={leagueId} value={leagueId}>
|
||||
{item.leagueName}
|
||||
</option>
|
||||
) : null;
|
||||
})}
|
||||
</select>
|
||||
<GridItem colSpan={12} lgSpan={4}>
|
||||
<RaceSidebar
|
||||
upcomingRaces={viewData.upcomingRaces}
|
||||
recentResults={viewData.recentResults}
|
||||
onRaceClick={onRaceClick}
|
||||
/>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
|
||||
{/* Filter Button */}
|
||||
<button
|
||||
onClick={() => setShowFilterModal(true)}
|
||||
className="px-4 py-2 bg-iron-gray border border-charcoal-outline rounded-lg text-white text-sm hover:border-primary-blue transition-colors"
|
||||
>
|
||||
More Filters
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Race List by Date */}
|
||||
{filteredRaces.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="p-4 bg-iron-gray rounded-full">
|
||||
<Calendar className="w-8 h-8 text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium mb-1">No races found</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{totalCount === 0
|
||||
? 'No races have been scheduled yet'
|
||||
: 'Try adjusting your filters'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Array.from(racesByDate.entries()).map(([dateKey, dayRaces]) => (
|
||||
<div key={dateKey} className="space-y-3">
|
||||
{/* Date Header */}
|
||||
<div className="flex items-center gap-3 px-2">
|
||||
<div className="p-2 bg-primary-blue/10 rounded-lg">
|
||||
<Calendar className="w-4 h-4 text-primary-blue" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{formatFullDate(new Date(dateKey))}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{dayRaces.length} race{dayRaces.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Races for this date */}
|
||||
<div className="space-y-2">
|
||||
{dayRaces.map((race) => {
|
||||
const config = statusConfig[race.status as keyof typeof statusConfig];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={race.id}
|
||||
className={`group relative overflow-hidden rounded-xl bg-iron-gray border ${config.border} p-4 cursor-pointer transition-all duration-200 hover:scale-[1.01] hover:border-primary-blue`}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
>
|
||||
{/* Live indicator */}
|
||||
{race.status === 'running' && (
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-performance-green via-performance-green/50 to-performance-green animate-pulse" />
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Time Column */}
|
||||
<div className="flex-shrink-0 text-center min-w-[60px]">
|
||||
<p className="text-lg font-bold text-white">
|
||||
{formatTime(race.scheduledAt)}
|
||||
</p>
|
||||
<p className={`text-xs ${config.color}`}>
|
||||
{race.status === 'running'
|
||||
? 'LIVE'
|
||||
: getRelativeTime(race.scheduledAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className={`w-px self-stretch ${config.bg}`} />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-white truncate group-hover:text-primary-blue transition-colors">
|
||||
{race.track}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="flex items-center gap-1 text-sm text-gray-400">
|
||||
<Car className="w-3.5 h-3.5" />
|
||||
{race.car}
|
||||
</span>
|
||||
{race.strengthOfField && (
|
||||
<span className="flex items-center gap-1 text-sm text-gray-400">
|
||||
<Zap className="w-3.5 h-3.5 text-warning-amber" />
|
||||
SOF {race.strengthOfField}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full ${config.bg} ${config.border} border`}>
|
||||
<StatusIcon className={`w-3.5 h-3.5 ${config.color}`} />
|
||||
<span className={`text-xs font-medium ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* League Link */}
|
||||
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
|
||||
<Link
|
||||
href={`/leagues/${race.leagueId ?? ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-2 text-sm text-primary-blue hover:underline"
|
||||
>
|
||||
<Trophy className="w-3.5 h-3.5" />
|
||||
{race.leagueName}
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-primary-blue transition-colors flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View All Link */}
|
||||
{filteredRaces.length > 0 && (
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/races"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-iron-gray border border-charcoal-outline rounded-lg text-white hover:border-primary-blue transition-colors"
|
||||
>
|
||||
View All Races
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Upcoming This Week */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-white flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-primary-blue" />
|
||||
Next Up
|
||||
</h3>
|
||||
<span className="text-xs text-gray-500">This week</span>
|
||||
</div>
|
||||
|
||||
{upcomingRaces.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4">
|
||||
No races scheduled this week
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{upcomingRaces.map((race) => {
|
||||
if (!race.scheduledAt) {
|
||||
return null;
|
||||
}
|
||||
const scheduledAtDate = new Date(race.scheduledAt);
|
||||
return (
|
||||
<div
|
||||
key={race.id}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-primary-blue/10 rounded-lg flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-primary-blue">
|
||||
{scheduledAtDate.getDate()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white truncate">{race.track}</p>
|
||||
<p className="text-xs text-gray-500">{formatTime(scheduledAtDate)}</p>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Recent Results */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-white flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4 text-warning-amber" />
|
||||
Recent Results
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{recentResults.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4">
|
||||
No completed races yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentResults.map((race) => (
|
||||
<div
|
||||
key={race.id}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-gray-500/10 rounded-lg flex items-center justify-center">
|
||||
<CheckCircle2 className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white truncate">{race.track}</p>
|
||||
<p className="text-xs text-gray-500">{formatDate(new Date(race.scheduledAt))}</p>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<h3 className="font-semibold text-white mb-4">Quick Actions</h3>
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href="/leagues"
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite hover:bg-charcoal-outline/50 transition-colors"
|
||||
>
|
||||
<div className="p-2 bg-primary-blue/10 rounded-lg">
|
||||
<Users className="w-4 h-4 text-primary-blue" />
|
||||
</div>
|
||||
<span className="text-sm text-white">Browse Leagues</span>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500 ml-auto" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/leaderboards"
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite hover:bg-charcoal-outline/50 transition-colors"
|
||||
>
|
||||
<div className="p-2 bg-warning-amber/10 rounded-lg">
|
||||
<Trophy className="w-4 h-4 text-warning-amber" />
|
||||
</div>
|
||||
<span className="text-sm text-white">View Leaderboards</span>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500 ml-auto" />
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Modal */}
|
||||
<RaceFilterModal
|
||||
isOpen={showFilterModal}
|
||||
onClose={() => setShowFilterModal(false)}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
leagueFilter={leagueFilter}
|
||||
setLeagueFilter={setLeagueFilter}
|
||||
timeFilter={timeFilter}
|
||||
setTimeFilter={setTimeFilter}
|
||||
searchQuery=""
|
||||
setSearchQuery={() => {}}
|
||||
leagues={[...new Set(races.map(r => ({ id: r.leagueId || '', name: r.leagueName || '' })))]}
|
||||
showSearch={false}
|
||||
showTimeFilter={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<RaceFilterModal
|
||||
isOpen={showFilterModal}
|
||||
onClose={() => setShowFilterModal(false)}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
leagueFilter={leagueFilter}
|
||||
setLeagueFilter={setLeagueFilter}
|
||||
timeFilter={timeFilter}
|
||||
setTimeFilter={setTimeFilter}
|
||||
searchQuery=""
|
||||
setSearchQuery={() => {}}
|
||||
leagues={viewData.leagues}
|
||||
showSearch={false}
|
||||
showTimeFilter={false}
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Section } from '@/ui/Section';
|
||||
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 { Heading } from '@/ui/Heading';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import type { MembershipRole } from '@/lib/types/MembershipRole';
|
||||
import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO';
|
||||
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
|
||||
import type { LeagueRosterAdminViewData } from '@/lib/view-data/LeagueRosterAdminViewData';
|
||||
|
||||
interface RosterAdminTemplateProps {
|
||||
joinRequests: LeagueRosterJoinRequestDTO[];
|
||||
members: LeagueRosterMemberDTO[];
|
||||
viewData: LeagueRosterAdminViewData;
|
||||
loading: boolean;
|
||||
pendingCountLabel: string;
|
||||
onApprove: (requestId: string) => Promise<void>;
|
||||
@@ -20,8 +24,7 @@ interface RosterAdminTemplateProps {
|
||||
}
|
||||
|
||||
export function RosterAdminTemplate({
|
||||
joinRequests,
|
||||
members,
|
||||
viewData,
|
||||
loading,
|
||||
pendingCountLabel,
|
||||
onApprove,
|
||||
@@ -30,136 +33,122 @@ export function RosterAdminTemplate({
|
||||
onRemove,
|
||||
roleOptions,
|
||||
}: RosterAdminTemplateProps) {
|
||||
const { joinRequests, members } = viewData;
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<Stack gap={6}>
|
||||
<Card>
|
||||
<Section>
|
||||
<Section>
|
||||
<Text size="2xl" weight="bold" className="text-white">
|
||||
Roster Admin
|
||||
</Text>
|
||||
<Text size="sm" className="text-gray-400">
|
||||
<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>
|
||||
</Section>
|
||||
</Box>
|
||||
|
||||
<Section>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Text size="lg" weight="semibold" className="text-white">
|
||||
Pending join requests
|
||||
</Text>
|
||||
<Text size="xs" className="text-gray-500">
|
||||
<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>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{loading ? (
|
||||
<Text size="sm" className="text-gray-400">
|
||||
Loading…
|
||||
</Text>
|
||||
) : joinRequests.length ? (
|
||||
<div className="space-y-2">
|
||||
<Text size="sm" color="text-gray-400">Loading…</Text>
|
||||
) : joinRequests.length > 0 ? (
|
||||
<Stack gap={3}>
|
||||
{joinRequests.map((req) => (
|
||||
<div
|
||||
<Surface
|
||||
key={req.id}
|
||||
className="flex items-center justify-between gap-3 bg-deep-graphite border border-charcoal-outline rounded p-3"
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
border
|
||||
padding={3}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<Text weight="medium" className="text-white truncate">
|
||||
{(req.driver as any)?.name || 'Unknown'}
|
||||
</Text>
|
||||
<Text size="xs" className="text-gray-400 truncate">
|
||||
{req.requestedAt}
|
||||
</Text>
|
||||
{req.message && (
|
||||
<Text size="xs" className="text-gray-500 truncate">
|
||||
{req.message}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
data-testid={`join-request-${req.id}-approve`}
|
||||
onClick={() => onApprove(req.id)}
|
||||
className="bg-primary-blue text-white"
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
data-testid={`join-request-${req.id}-reject`}
|
||||
onClick={() => onReject(req.id)}
|
||||
className="bg-iron-gray text-gray-200"
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="sm" className="text-gray-500">
|
||||
No pending join requests.
|
||||
</Text>
|
||||
<Text size="sm" color="text-gray-500">No pending join requests.</Text>
|
||||
)}
|
||||
</Section>
|
||||
</Box>
|
||||
|
||||
<Section>
|
||||
<Text size="lg" weight="semibold" className="text-white">
|
||||
Members
|
||||
</Text>
|
||||
<Box pt={6} style={{ borderTop: '1px solid #262626' }}>
|
||||
<Box mb={4}>
|
||||
<Heading level={2}>Members</Heading>
|
||||
</Box>
|
||||
|
||||
{loading ? (
|
||||
<Text size="sm" className="text-gray-400">
|
||||
Loading…
|
||||
</Text>
|
||||
) : members.length ? (
|
||||
<div className="space-y-2">
|
||||
<Text size="sm" color="text-gray-400">Loading…</Text>
|
||||
) : members.length > 0 ? (
|
||||
<Stack gap={3}>
|
||||
{members.map((member) => (
|
||||
<div
|
||||
<Surface
|
||||
key={member.driverId}
|
||||
className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 bg-deep-graphite border border-charcoal-outline rounded p-3"
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
border
|
||||
padding={3}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<Text weight="medium" className="text-white truncate">
|
||||
{(member.driver as any)?.name || 'Unknown'}
|
||||
</Text>
|
||||
<Text size="xs" className="text-gray-400 truncate">
|
||||
{member.joinedAt}
|
||||
</Text>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-2">
|
||||
<label className="text-xs text-gray-400" htmlFor={`role-${member.driverId}`}>
|
||||
Role for {(member.driver as any)?.name || 'Unknown'}
|
||||
</label>
|
||||
<Select
|
||||
id={`role-${member.driverId}`}
|
||||
aria-label={`Role for ${(member.driver as any)?.name || 'Unknown'}`}
|
||||
value={member.role}
|
||||
onChange={(e) => onRoleChange(member.driverId, e.target.value as MembershipRole)}
|
||||
options={roleOptions.map((role) => ({ value: role, label: role }))}
|
||||
className="bg-iron-gray text-white px-3 py-2 rounded"
|
||||
/>
|
||||
<Button
|
||||
data-testid={`member-${member.driverId}-remove`}
|
||||
onClick={() => onRemove(member.driverId)}
|
||||
className="bg-iron-gray text-gray-200"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="sm" className="text-gray-500">
|
||||
No members found.
|
||||
</Text>
|
||||
<Text size="sm" color="text-gray-500">No members found.</Text>
|
||||
)}
|
||||
</Section>
|
||||
</Section>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { RulebookViewData } from '@/lib/view-data/leagues/RulebookViewData';
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Section } from '@/ui/Section';
|
||||
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 type { RulebookViewData } from '@/lib/view-data/leagues/RulebookViewData';
|
||||
|
||||
interface RulebookTemplateProps {
|
||||
viewData: RulebookViewData;
|
||||
@@ -8,95 +18,103 @@ interface RulebookTemplateProps {
|
||||
|
||||
export function RulebookTemplate({ viewData }: RulebookTemplateProps) {
|
||||
return (
|
||||
<Section>
|
||||
<Stack gap={6}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Rulebook</h1>
|
||||
<p className="text-sm text-gray-400 mt-1">Official rules and regulations</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-blue/10 border border-primary-blue/20">
|
||||
<span className="text-sm font-medium text-primary-blue">{viewData.scoringPresetName || 'Custom Rules'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<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 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Platform</p>
|
||||
<p className="text-lg font-semibold text-white">{viewData.gameName}</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Championships</p>
|
||||
<p className="text-lg font-semibold text-white">{viewData.championshipsCount}</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Sessions Scored</p>
|
||||
<p className="text-lg font-semibold text-white capitalize">
|
||||
{viewData.sessionTypes}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Drop Policy</p>
|
||||
<p className="text-lg font-semibold text-white truncate" title={viewData.dropPolicySummary}>
|
||||
{viewData.hasActiveDropPolicy ? 'Active' : 'None'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Grid cols={4} gap={4}>
|
||||
<StatItem label="Platform" value={viewData.gameName} />
|
||||
<StatItem label="Championships" value={viewData.championshipsCount} />
|
||||
<StatItem label="Sessions Scored" value={viewData.sessionTypes} capitalize />
|
||||
<StatItem label="Drop Policy" value={viewData.hasActiveDropPolicy ? 'Active' : 'None'} />
|
||||
</Grid>
|
||||
|
||||
{/* Points Table */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Points System</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-charcoal-outline">
|
||||
<th className="text-left py-2 font-medium text-gray-400">Position</th>
|
||||
<th className="text-left py-2 font-medium text-gray-400">Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{viewData.positionPoints.map((point) => (
|
||||
<tr key={point.position} className="border-b border-charcoal-outline/50">
|
||||
<td className="py-3 text-white">{point.position}</td>
|
||||
<td className="py-3 text-white">{point.points}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<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>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Bonus Points</h2>
|
||||
<div className="space-y-2">
|
||||
<Box mb={4}>
|
||||
<Heading level={2}>Bonus Points</Heading>
|
||||
</Box>
|
||||
<Stack gap={2}>
|
||||
{viewData.bonusPoints.map((bonus, idx) => (
|
||||
<div
|
||||
<Surface
|
||||
key={idx}
|
||||
className="flex items-center gap-4 p-3 bg-deep-graphite rounded-lg border border-charcoal-outline"
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
border
|
||||
padding={3}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-performance-green/10 border border-performance-green/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-performance-green text-sm font-bold">+</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">{bonus}</p>
|
||||
</div>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Surface variant="muted" rounded="full" padding={1} style={{ width: '2rem', height: '2rem', backgroundColor: 'rgba(16, 185, 129, 0.1)', border: '1px solid rgba(16, 185, 129, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text color="text-performance-green" weight="bold">+</Text>
|
||||
</Surface>
|
||||
<Text size="sm" color="text-gray-300">{bonus}</Text>
|
||||
</Stack>
|
||||
</Surface>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Drop Policy */}
|
||||
{viewData.hasActiveDropPolicy && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Drop Policy</h2>
|
||||
<p className="text-sm text-gray-300">{viewData.dropPolicySummary}</p>
|
||||
<p className="text-xs text-gray-500 mt-3">
|
||||
Drop rules are applied automatically when calculating championship standings.
|
||||
</p>
|
||||
<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.
|
||||
</Text>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
</Section>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function StatItem({ label, value, capitalize }: { label: string, value: string | number, capitalize?: boolean }) {
|
||||
return (
|
||||
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: '#262626', borderColor: '#262626' }}>
|
||||
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block mb={1}>{label}</Text>
|
||||
<Text weight="semibold" color="text-white" style={{ fontSize: '1.125rem', textTransform: capitalize ? 'capitalize' : 'none' }}>{value}</Text>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import InfoBanner from '@/components/ui/InfoBanner';
|
||||
import MetricCard from '@/components/sponsors/MetricCard';
|
||||
import SponsorshipCategoryCard from '@/components/sponsors/SponsorshipCategoryCard';
|
||||
import ActivityItem from '@/components/sponsors/ActivityItem';
|
||||
import RenewalAlert from '@/components/sponsors/RenewalAlert';
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
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 { MetricCard } from '@/components/sponsors/MetricCard';
|
||||
import { SponsorshipCategoryCard } from '@/components/sponsors/SponsorshipCategoryCard';
|
||||
import { ActivityItem } from '@/components/sponsors/ActivityItem';
|
||||
import { RenewalAlert } from '@/components/sponsors/RenewalAlert';
|
||||
import {
|
||||
BarChart3,
|
||||
Eye,
|
||||
Users,
|
||||
Trophy,
|
||||
TrendingUp,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Target,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Car,
|
||||
Flag,
|
||||
Megaphone,
|
||||
@@ -29,308 +35,325 @@ import {
|
||||
Settings,
|
||||
CreditCard,
|
||||
FileText,
|
||||
RefreshCw
|
||||
RefreshCw,
|
||||
BarChart3,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import type { SponsorDashboardViewData } from '@/lib/view-data/SponsorDashboardViewData';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
interface SponsorDashboardTemplateProps {
|
||||
viewData: SponsorDashboardViewData;
|
||||
}
|
||||
|
||||
export function SponsorDashboardTemplate({ viewData }: SponsorDashboardTemplateProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const categoryData = viewData.categoryData;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-8 px-4">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">Sponsor Dashboard</h2>
|
||||
<p className="text-gray-400">Welcome back, {viewData.sponsorName}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Time Range Selector */}
|
||||
<div className="flex items-center bg-iron-gray/50 rounded-lg p-1">
|
||||
{(['7d', '30d', '90d', 'all'] as const).map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
onClick={() => {}}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
false
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{range === 'all' ? 'All' : range}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Button variant="secondary" className="hidden sm:flex">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Link href=routes.sponsor.settings>
|
||||
<Button variant="secondary" className="hidden sm:flex">
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<MetricCard
|
||||
title="Total Impressions"
|
||||
value={viewData.totalImpressions}
|
||||
change={viewData.metrics.impressionsChange}
|
||||
icon={Eye}
|
||||
delay={0}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Unique Viewers"
|
||||
value="12.5k" // Mock
|
||||
change={viewData.metrics.viewersChange}
|
||||
icon={Users}
|
||||
delay={0.1}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Engagement Rate"
|
||||
value="4.2%" // Mock
|
||||
change={viewData.metrics.exposureChange}
|
||||
icon={TrendingUp}
|
||||
suffix="%"
|
||||
delay={0.2}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Total Investment"
|
||||
value={viewData.totalInvestment}
|
||||
icon={DollarSign}
|
||||
prefix="$"
|
||||
delay={0.3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sponsorship Categories */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white">Your Sponsorships</h3>
|
||||
<Link href=routes.sponsor.campaigns>
|
||||
<Button variant="secondary" className="text-sm">
|
||||
View All
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
<SponsorshipCategoryCard
|
||||
icon={Trophy}
|
||||
title="Leagues"
|
||||
count={categoryData.leagues.count}
|
||||
impressions={categoryData.leagues.impressions}
|
||||
color="text-primary-blue"
|
||||
href="/sponsor/campaigns?type=leagues"
|
||||
/>
|
||||
<SponsorshipCategoryCard
|
||||
icon={Users}
|
||||
title="Teams"
|
||||
count={categoryData.teams.count}
|
||||
impressions={categoryData.teams.impressions}
|
||||
color="text-purple-400"
|
||||
href="/sponsor/campaigns?type=teams"
|
||||
/>
|
||||
<SponsorshipCategoryCard
|
||||
icon={Car}
|
||||
title="Drivers"
|
||||
count={categoryData.drivers.count}
|
||||
impressions={categoryData.drivers.impressions}
|
||||
color="text-performance-green"
|
||||
href="/sponsor/campaigns?type=drivers"
|
||||
/>
|
||||
<SponsorshipCategoryCard
|
||||
icon={Flag}
|
||||
title="Races"
|
||||
count={categoryData.races.count}
|
||||
impressions={categoryData.races.impressions}
|
||||
color="text-warning-amber"
|
||||
href="/sponsor/campaigns?type=races"
|
||||
/>
|
||||
<SponsorshipCategoryCard
|
||||
icon={Megaphone}
|
||||
title="Platform Ads"
|
||||
count={categoryData.platform.count}
|
||||
impressions={categoryData.platform.impressions}
|
||||
color="text-racing-red"
|
||||
href="/sponsor/campaigns?type=platform"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - Sponsored Entities */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Top Performing Sponsorships */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline">
|
||||
<h3 className="text-lg font-semibold text-white">Top Performing</h3>
|
||||
<Link href="/leagues">
|
||||
<Button variant="secondary" className="text-sm">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Find More
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="divide-y divide-charcoal-outline/50">
|
||||
{/* Mock data for now */}
|
||||
<div className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="px-2 py-1 rounded text-xs font-medium bg-primary-blue/20 text-primary-blue border border-primary-blue/30">
|
||||
Main
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4 text-gray-500" />
|
||||
<span className="font-medium text-white">Sample League</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Sample details</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-white">1.2k</div>
|
||||
<div className="text-xs text-gray-500">impressions</div>
|
||||
</div>
|
||||
<Button variant="secondary" className="text-xs">
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Upcoming Events */}
|
||||
<Card>
|
||||
<div className="p-4 border-b border-charcoal-outline">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-warning-amber" />
|
||||
Upcoming Sponsored Events
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Calendar className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No upcoming sponsored events</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Activity & Quick Actions */}
|
||||
<div className="space-y-6">
|
||||
{/* Quick Actions */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Quick Actions</h3>
|
||||
<div className="space-y-2">
|
||||
<Link href="/leagues" className="block">
|
||||
<Button variant="secondary" className="w-full justify-start">
|
||||
<Target className="w-4 h-4 mr-2" />
|
||||
Find Leagues to Sponsor
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/teams" className="block">
|
||||
<Button variant="secondary" className="w-full justify-start">
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
Browse Teams
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/drivers" className="block">
|
||||
<Button variant="secondary" className="w-full justify-start">
|
||||
<Car className="w-4 h-4 mr-2" />
|
||||
Discover Drivers
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href=routes.sponsor.billing className="block">
|
||||
<Button variant="secondary" className="w-full justify-start">
|
||||
<CreditCard className="w-4 h-4 mr-2" />
|
||||
Manage Billing
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href=routes.sponsor.campaigns className="block">
|
||||
<Button variant="secondary" className="w-full justify-start">
|
||||
<BarChart3 className="w-4 h-4 mr-2" />
|
||||
View Analytics
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Renewal Alerts */}
|
||||
{viewData.upcomingRenewals.length > 0 && (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Bell className="w-5 h-5 text-warning-amber" />
|
||||
Upcoming Renewals
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{viewData.upcomingRenewals.map((renewal: any) => (
|
||||
<RenewalAlert key={renewal.id} renewal={renewal} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
</Surface>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Recent Activity</h3>
|
||||
<div>
|
||||
{viewData.recentActivity.map((activity: any) => (
|
||||
<ActivityItem key={activity.id} activity={activity} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<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>
|
||||
|
||||
{/* Investment Summary */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-primary-blue" />
|
||||
Investment Summary
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400">Active Sponsorships</span>
|
||||
<span className="font-medium text-white">{viewData.activeSponsorships}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400">Total Investment</span>
|
||||
<span className="font-medium text-white">{viewData.formattedTotalInvestment}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400">Cost per 1K Views</span>
|
||||
<span className="font-medium text-performance-green">
|
||||
{viewData.costPerThousandViews}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400">Next Invoice</span>
|
||||
<span className="font-medium text-white">Jan 1, 2026</span>
|
||||
</div>
|
||||
<div className="pt-3 border-t border-charcoal-outline">
|
||||
<Link href=routes.sponsor.billing>
|
||||
<Button variant="secondary" className="w-full text-sm">
|
||||
<CreditCard className="w-4 h-4 mr-2" />
|
||||
View Billing Details
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Key Metrics */}
|
||||
<Grid cols={4} gap={4}>
|
||||
<MetricCard
|
||||
title="Total Impressions"
|
||||
value={viewData.totalImpressions}
|
||||
change={viewData.metrics.impressionsChange}
|
||||
icon={Eye}
|
||||
delay={0}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Unique Viewers"
|
||||
value="12.5k"
|
||||
change={viewData.metrics.viewersChange}
|
||||
icon={Users}
|
||||
delay={0.1}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Engagement Rate"
|
||||
value="4.2%"
|
||||
change={viewData.metrics.exposureChange}
|
||||
icon={TrendingUp}
|
||||
suffix="%"
|
||||
delay={0.2}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Total Investment"
|
||||
value={viewData.totalInvestment}
|
||||
icon={DollarSign}
|
||||
prefix="$"
|
||||
delay={0.3}
|
||||
/>
|
||||
</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} style={{ borderBottom: '1px solid #262626' }}>
|
||||
<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 style={{ 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>
|
||||
|
||||
{/* Upcoming Events */}
|
||||
<Card p={0}>
|
||||
<Box p={4} style={{ borderBottom: '1px solid #262626' }}>
|
||||
<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>
|
||||
</Stack>
|
||||
</GridItem>
|
||||
|
||||
<GridItem colSpan={12} lgSpan={4}>
|
||||
<Stack gap={6}>
|
||||
{/* 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>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Renewal Alerts */}
|
||||
{viewData.upcomingRenewals.length > 0 && (
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Heading level={3} icon={<Icon icon={Bell} size={5} color="#f59e0b" />}>
|
||||
Upcoming Renewals
|
||||
</Heading>
|
||||
<Stack gap={3}>
|
||||
{viewData.upcomingRenewals.map((renewal) => (
|
||||
<RenewalAlert key={renewal.id} renewal={renewal} />
|
||||
))}
|
||||
</Stack>
|
||||
</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} style={{ borderTop: '1px solid #262626' }}>
|
||||
<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>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { siteConfig } from '@/lib/siteConfig';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Trophy,
|
||||
Users,
|
||||
Calendar,
|
||||
Eye,
|
||||
TrendingUp,
|
||||
Download,
|
||||
Image as ImageIcon,
|
||||
ExternalLink,
|
||||
ChevronRight,
|
||||
Star,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
Flag,
|
||||
Car,
|
||||
BarChart3,
|
||||
ArrowUpRight,
|
||||
Megaphone,
|
||||
CreditCard,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/ui/Card';
|
||||
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 { SponsorTierCard } from '@/components/sponsors/SponsorTierCard';
|
||||
import { siteConfig } from '@/lib/siteConfig';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
interface SponsorLeagueDetailData {
|
||||
league: {
|
||||
@@ -94,485 +98,322 @@ interface SponsorLeagueDetailData {
|
||||
}
|
||||
|
||||
interface SponsorLeagueDetailTemplateProps {
|
||||
data: SponsorLeagueDetailData;
|
||||
viewData: SponsorLeagueDetailData;
|
||||
}
|
||||
|
||||
type TabType = 'overview' | 'drivers' | 'races' | 'sponsor';
|
||||
|
||||
export function SponsorLeagueDetailTemplate({ data }: SponsorLeagueDetailTemplateProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
export function SponsorLeagueDetailTemplate({ viewData }: SponsorLeagueDetailTemplateProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||
const [selectedTier, setSelectedTier] = useState<'main' | 'secondary'>('main');
|
||||
|
||||
const league = data.league;
|
||||
const config = league.tierConfig;
|
||||
const league = viewData.league;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-8 px-4">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 mb-6">
|
||||
<Link href=routes.sponsor.dashboard className="hover:text-white transition-colors">Dashboard</Link>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<Link href=routes.sponsor.leagues className="hover:text-white transition-colors">Leagues</Link>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<span className="text-white">{league.name}</span>
|
||||
</div>
|
||||
<Container size="lg" py={8}>
|
||||
<Stack gap={8}>
|
||||
{/* Breadcrumb */}
|
||||
<Box>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Link href={routes.sponsor.dashboard}>
|
||||
<Text size="sm" color="text-gray-400">Dashboard</Text>
|
||||
</Link>
|
||||
<Text size="sm" color="text-gray-500">/</Text>
|
||||
<Link href={routes.sponsor.leagues}>
|
||||
<Text size="sm" color="text-gray-400">Leagues</Text>
|
||||
</Link>
|
||||
<Text size="sm" color="text-gray-500">/</Text>
|
||||
<Text size="sm" color="text-white">{league.name}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-start justify-between gap-6 mb-8">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium capitalize ${config.bgColor} ${config.color} border ${config.border}`}>
|
||||
⭐ {league.tier}
|
||||
</span>
|
||||
<span className="px-3 py-1 rounded-full text-sm font-medium bg-performance-green/10 text-performance-green">
|
||||
Active Season
|
||||
</span>
|
||||
<div className="flex items-center gap-1 px-2 py-1 rounded bg-iron-gray/50">
|
||||
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
|
||||
<span className="text-sm font-medium text-white">{league.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">{league.name}</h1>
|
||||
<p className="text-gray-400 mb-4">{league.game} • {league.season} • {league.completedRaces}/{league.races} races completed</p>
|
||||
<p className="text-gray-400 max-w-2xl">{league.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<Link href={`/leagues/${league.id}`}>
|
||||
<Button variant="secondary">
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
View League
|
||||
</Button>
|
||||
</Link>
|
||||
{(league.sponsorSlots.main.available || league.sponsorSlots.secondary.available > 0) && (
|
||||
<Button variant="primary" onClick={() => setActiveTab('sponsor')}>
|
||||
<Megaphone className="w-4 h-4 mr-2" />
|
||||
Become a Sponsor
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Header */}
|
||||
<Stack direction="row" align="start" justify="between" wrap gap={6}>
|
||||
<Box style={{ flex: 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} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)', paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
|
||||
<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} style={{ maxWidth: '42rem' }}>
|
||||
{league.description}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" gap={3}>
|
||||
<Link href={`/leagues/${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>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10">
|
||||
<Eye className="w-5 h-5 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-white">{league.formattedTotalImpressions}</div>
|
||||
<div className="text-xs text-gray-400">Total Views</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10">
|
||||
<TrendingUp className="w-5 h-5 text-performance-green" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-white">{league.formattedAvgViewsPerRace}</div>
|
||||
<div className="text-xs text-gray-400">Avg/Race</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10">
|
||||
<Users className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-white">{league.drivers}</div>
|
||||
<div className="text-xs text-gray-400">Drivers</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/10">
|
||||
<BarChart3 className="w-5 h-5 text-warning-amber" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-white">{league.engagement}%</div>
|
||||
<div className="text-xs text-gray-400">Engagement</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-racing-red/10">
|
||||
<Calendar className="w-5 h-5 text-racing-red" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-white">{league.racesLeft}</div>
|
||||
<div className="text-xs text-gray-400">Races Left</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
{/* 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>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-6 border-b border-charcoal-outline overflow-x-auto">
|
||||
{(['overview', 'drivers', 'races', 'sponsor'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-3 text-sm font-medium capitalize transition-colors border-b-2 -mb-px whitespace-nowrap ${
|
||||
activeTab === tab
|
||||
? 'text-primary-blue border-primary-blue'
|
||||
: 'text-gray-400 border-transparent hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{tab === 'sponsor' ? '🎯 Become a Sponsor' : tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Tabs */}
|
||||
<Box style={{ borderBottom: '1px solid #262626' }}>
|
||||
<Stack direction="row" gap={6}>
|
||||
{(['overview', 'drivers', 'races', 'sponsor'] as const).map((tab) => (
|
||||
<Box
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
pb={3}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderBottom: activeTab === tab ? '2px solid #3b82f6' : '2px solid transparent',
|
||||
color: activeTab === tab ? '#3b82f6' : '#9ca3af'
|
||||
}}
|
||||
>
|
||||
<Text size="sm" weight="medium" style={{ textTransform: 'capitalize' }}>
|
||||
{tab === 'sponsor' ? '🎯 Become a Sponsor' : tab}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="p-5">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Trophy className="w-5 h-5 text-primary-blue" />
|
||||
League Information
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-400">Platform</span>
|
||||
<span className="text-white font-medium">{league.game}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-400">Season</span>
|
||||
<span className="text-white font-medium">{league.season}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-400">Duration</span>
|
||||
<span className="text-white font-medium">Oct 2025 - Feb 2026</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-400">Drivers</span>
|
||||
<span className="text-white font-medium">{league.drivers}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-gray-400">Races</span>
|
||||
<span className="text-white font-medium">{league.races}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<Grid cols={2} gap={6}>
|
||||
<Card>
|
||||
<Box mb={4}>
|
||||
<Heading level={2} icon={<Icon icon={Trophy} size={5} color="#3b82f6" />}>
|
||||
League Information
|
||||
</Heading>
|
||||
</Box>
|
||||
<Stack gap={3}>
|
||||
<InfoRow label="Platform" value={league.game} />
|
||||
<InfoRow label="Season" value={league.season} />
|
||||
<InfoRow label="Duration" value="Oct 2025 - Feb 2026" />
|
||||
<InfoRow label="Drivers" value={league.drivers} />
|
||||
<InfoRow label="Races" value={league.races} last />
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Box mb={4}>
|
||||
<Heading level={2} icon={<Icon icon={TrendingUp} size={5} color="#10b981" />}>
|
||||
Sponsorship Value
|
||||
</Heading>
|
||||
</Box>
|
||||
<Stack gap={3}>
|
||||
<InfoRow label="Total Season Views" value={league.formattedTotalImpressions} />
|
||||
<InfoRow label="Projected Total" value={league.formattedProjectedTotal} />
|
||||
<InfoRow label="Main Sponsor CPM" value={league.formattedMainSponsorCpm} color="text-performance-green" />
|
||||
<InfoRow label="Engagement Rate" value={`${league.engagement}%`} />
|
||||
<InfoRow label="League Rating" value={`${league.rating}/5.0`} last />
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{league.nextRace && (
|
||||
<GridItem colSpan={2}>
|
||||
<Card>
|
||||
<Box mb={4}>
|
||||
<Heading level={2} icon={<Icon icon={Flag} size={5} color="#f59e0b" />}>
|
||||
Next Race
|
||||
</Heading>
|
||||
</Box>
|
||||
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(245, 158, 11, 0.05)', borderColor: 'rgba(245, 158, 11, 0.2)' }}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Surface variant="muted" rounded="lg" padding={3} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)' }}>
|
||||
<Icon icon={Flag} size={6} color="#f59e0b" />
|
||||
</Surface>
|
||||
<Box>
|
||||
<Text size="lg" weight="semibold" color="text-white" block>{league.nextRace.name}</Text>
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>{league.nextRace.date}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Button variant="secondary">
|
||||
View Schedule
|
||||
</Button>
|
||||
</Stack>
|
||||
</Surface>
|
||||
</Card>
|
||||
</GridItem>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{activeTab === 'drivers' && (
|
||||
<Card p={0}>
|
||||
<Box p={4} style={{ borderBottom: '1px solid #262626' }}>
|
||||
<Heading level={2}>Championship Standings</Heading>
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>Top drivers carrying sponsor branding</Text>
|
||||
</Box>
|
||||
<Stack gap={0}>
|
||||
{viewData.drivers.map((driver, index) => (
|
||||
<Box key={driver.id} p={4} style={{ borderBottom: index < viewData.drivers.length - 1 ? '1px solid rgba(38, 38, 38, 0.5)' : 'none' }}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Surface variant="muted" rounded="full" padding={1} style={{ width: '2.5rem', height: '2.5rem', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#262626' }}>
|
||||
<Text weight="bold" color="text-white">{driver.position}</Text>
|
||||
</Surface>
|
||||
<Box>
|
||||
<Text weight="medium" color="text-white" block>{driver.name}</Text>
|
||||
<Text size="sm" color="text-gray-500" block mt={1}>{driver.team} • {driver.country}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={8}>
|
||||
<Box style={{ textAlign: 'right' }}>
|
||||
<Text weight="medium" color="text-white" block>{driver.races}</Text>
|
||||
<Text size="xs" color="text-gray-500">races</Text>
|
||||
</Box>
|
||||
<Box style={{ textAlign: 'right' }}>
|
||||
<Text weight="semibold" color="text-white" block>{driver.formattedImpressions}</Text>
|
||||
<Text size="xs" color="text-gray-500">views</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="p-5">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-performance-green" />
|
||||
Sponsorship Value
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-400">Total Season Views</span>
|
||||
<span className="text-white font-medium">{league.formattedTotalImpressions}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-400">Projected Total</span>
|
||||
<span className="text-white font-medium">{league.formattedProjectedTotal}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-400">Main Sponsor CPM</span>
|
||||
<span className="text-performance-green font-medium">
|
||||
{league.formattedMainSponsorCpm}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-400">Engagement Rate</span>
|
||||
<span className="text-white font-medium">{league.engagement}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-gray-400">League Rating</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
|
||||
<span className="text-white font-medium">{league.rating}/5.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{activeTab === 'races' && (
|
||||
<Card p={0}>
|
||||
<Box p={4} style={{ borderBottom: '1px solid #262626' }}>
|
||||
<Heading level={2}>Race Calendar</Heading>
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>Season schedule with view statistics</Text>
|
||||
</Box>
|
||||
<Stack gap={0}>
|
||||
{viewData.races.map((race, index) => (
|
||||
<Box key={race.id} p={4} style={{ borderBottom: index < viewData.races.length - 1 ? '1px solid rgba(38, 38, 38, 0.5)' : 'none' }}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box style={{ width: '0.75rem', height: '0.75rem', borderRadius: '9999px', backgroundColor: race.status === 'completed' ? '#10b981' : '#f59e0b' }} />
|
||||
<Box>
|
||||
<Text weight="medium" color="text-white" block>{race.name}</Text>
|
||||
<Text size="sm" color="text-gray-500" block mt={1}>{race.formattedDate}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box>
|
||||
{race.status === 'completed' ? (
|
||||
<Box style={{ textAlign: 'right' }}>
|
||||
<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>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Next Race */}
|
||||
{league.nextRace && (
|
||||
<Card className="p-5 lg:col-span-2">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Flag className="w-5 h-5 text-warning-amber" />
|
||||
Next Race
|
||||
</h3>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-warning-amber/20 flex items-center justify-center">
|
||||
<Flag className="w-6 h-6 text-warning-amber" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-white text-lg">{league.nextRace.name}</p>
|
||||
<p className="text-sm text-gray-400">{league.nextRace.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary">
|
||||
View Schedule
|
||||
{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')}
|
||||
/>
|
||||
<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} style={{ borderTop: '1px solid #262626' }}>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<Button variant="secondary" icon={<Icon icon={FileText} size={4} />}>
|
||||
Download Info Pack
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'drivers' && (
|
||||
<Card>
|
||||
<div className="p-4 border-b border-charcoal-outline">
|
||||
<h3 className="text-lg font-semibold text-white">Championship Standings</h3>
|
||||
<p className="text-sm text-gray-400">Top drivers carrying sponsor branding</p>
|
||||
</div>
|
||||
<div className="divide-y divide-charcoal-outline/50">
|
||||
{data.drivers.map((driver) => (
|
||||
<div key={driver.id} className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center text-lg font-bold text-white">
|
||||
{driver.position}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-white">{driver.name}</div>
|
||||
<div className="text-sm text-gray-500">{driver.team} • {driver.country}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-right">
|
||||
<div className="font-medium text-white">{driver.races}</div>
|
||||
<div className="text-xs text-gray-500">races</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-white">{driver.formattedImpressions}</div>
|
||||
<div className="text-xs text-gray-500">views</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'races' && (
|
||||
<Card>
|
||||
<div className="p-4 border-b border-charcoal-outline">
|
||||
<h3 className="text-lg font-semibold text-white">Race Calendar</h3>
|
||||
<p className="text-sm text-gray-400">Season schedule with view statistics</p>
|
||||
</div>
|
||||
<div className="divide-y divide-charcoal-outline/50">
|
||||
{data.races.map((race) => (
|
||||
<div key={race.id} className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-3 h-3 rounded-full ${
|
||||
race.status === 'completed' ? 'bg-performance-green' : 'bg-warning-amber'
|
||||
}`} />
|
||||
<div>
|
||||
<div className="font-medium text-white">{race.name}</div>
|
||||
<div className="text-sm text-gray-500">{race.formattedDate}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{race.status === 'completed' ? (
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-white">{race.views.toLocaleString()}</div>
|
||||
<div className="text-xs text-gray-500">views</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="px-3 py-1 rounded-full text-xs font-medium bg-warning-amber/20 text-warning-amber">
|
||||
Upcoming
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'sponsor' && (
|
||||
<div className="space-y-6">
|
||||
{/* Tier Selection */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Main Sponsor */}
|
||||
<Card
|
||||
className={`p-5 cursor-pointer transition-all ${
|
||||
selectedTier === 'main'
|
||||
? 'border-primary-blue ring-2 ring-primary-blue/20'
|
||||
: 'hover:border-charcoal-outline/80'
|
||||
} ${!league.sponsorSlots.main.available ? 'opacity-60' : ''}`}
|
||||
onClick={() => league.sponsorSlots.main.available && setSelectedTier('main')}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Trophy className="w-5 h-5 text-yellow-400" />
|
||||
<h3 className="text-lg font-semibold text-white">Main Sponsor</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">Primary branding position</p>
|
||||
</div>
|
||||
{league.sponsorSlots.main.available ? (
|
||||
<span className="px-2 py-1 rounded text-xs font-medium bg-performance-green/20 text-performance-green">
|
||||
Available
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 rounded text-xs font-medium bg-gray-500/20 text-gray-400">
|
||||
Filled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-3xl font-bold text-white mb-4">
|
||||
${league.sponsorSlots.main.price}
|
||||
<span className="text-sm font-normal text-gray-500">/season</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2 mb-4">
|
||||
{league.sponsorSlots.main.benefits.map((benefit: string, i: number) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<CheckCircle2 className="w-4 h-4 text-performance-green flex-shrink-0" />
|
||||
{benefit}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{selectedTier === 'main' && league.sponsorSlots.main.available && (
|
||||
<div className="w-4 h-4 rounded-full bg-primary-blue absolute top-4 right-4 flex items-center justify-center">
|
||||
<CheckCircle2 className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Secondary Sponsor */}
|
||||
<Card
|
||||
className={`p-5 cursor-pointer transition-all ${
|
||||
selectedTier === 'secondary'
|
||||
? 'border-primary-blue ring-2 ring-primary-blue/20'
|
||||
: 'hover:border-charcoal-outline/80'
|
||||
} ${league.sponsorSlots.secondary.available === 0 ? 'opacity-60' : ''}`}
|
||||
onClick={() => league.sponsorSlots.secondary.available > 0 && setSelectedTier('secondary')}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Star className="w-5 h-5 text-purple-400" />
|
||||
<h3 className="text-lg font-semibold text-white">Secondary Sponsor</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">Supporting branding position</p>
|
||||
</div>
|
||||
{league.sponsorSlots.secondary.available > 0 ? (
|
||||
<span className="px-2 py-1 rounded text-xs font-medium bg-performance-green/20 text-performance-green">
|
||||
{league.sponsorSlots.secondary.available}/{league.sponsorSlots.secondary.total} Available
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 rounded text-xs font-medium bg-gray-500/20 text-gray-400">
|
||||
Full
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-3xl font-bold text-white mb-4">
|
||||
${league.sponsorSlots.secondary.price}
|
||||
<span className="text-sm font-normal text-gray-500">/season</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2 mb-4">
|
||||
{league.sponsorSlots.secondary.benefits.map((benefit: string, i: number) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<CheckCircle2 className="w-4 h-4 text-performance-green flex-shrink-0" />
|
||||
{benefit}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{selectedTier === 'secondary' && league.sponsorSlots.secondary.available > 0 && (
|
||||
<div className="w-4 h-4 rounded-full bg-primary-blue absolute top-4 right-4 flex items-center justify-center">
|
||||
<CheckCircle2 className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Checkout Summary */}
|
||||
<Card className="p-5">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<CreditCard className="w-5 h-5 text-primary-blue" />
|
||||
Sponsorship Summary
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-gray-400">Selected Tier</span>
|
||||
<span className="text-white font-medium capitalize">{selectedTier} Sponsor</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-gray-400">Season Price</span>
|
||||
<span className="text-white font-medium">
|
||||
${selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-gray-400">Platform Fee ({siteConfig.fees.platformFeePercent}%)</span>
|
||||
<span className="text-white font-medium">
|
||||
${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * siteConfig.fees.platformFeePercent / 100).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-t border-charcoal-outline pt-4">
|
||||
<span className="text-white font-semibold">Total (excl. VAT)</span>
|
||||
<span className="text-white font-bold text-xl">
|
||||
${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * (1 + siteConfig.fees.platformFeePercent / 100)).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
{siteConfig.vat.notice}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="primary" className="flex-1">
|
||||
<Megaphone className="w-4 h-4 mr-2" />
|
||||
Request Sponsorship
|
||||
</Button>
|
||||
<Button variant="secondary">
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Download Info Pack
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value, color }: { icon: any, label: string, value: string | number, color: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: `${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} style={{ borderBottom: last ? 'none' : '1px solid rgba(38, 38, 38, 0.5)' }}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Text color="text-gray-400">{label}</Text>
|
||||
<Text weight="medium" color={color as any}>{value}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { siteConfig } from '@/lib/siteConfig';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
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 {
|
||||
Trophy,
|
||||
Users,
|
||||
Eye,
|
||||
Search,
|
||||
Star,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
Car,
|
||||
Flag,
|
||||
TrendingUp,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Megaphone,
|
||||
ArrowUpDown
|
||||
} from 'lucide-react';
|
||||
import { siteConfig } from '@/lib/siteConfig';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { AvailableLeagueCard } from '@/components/sponsors/AvailableLeagueCard';
|
||||
|
||||
interface AvailableLeague {
|
||||
id: string;
|
||||
@@ -39,8 +41,6 @@ interface AvailableLeague {
|
||||
formattedAvgViews: string;
|
||||
formattedCpm: string;
|
||||
cpm: number;
|
||||
tierConfig: any;
|
||||
statusConfig: any;
|
||||
}
|
||||
|
||||
type SortOption = 'rating' | 'drivers' | 'price' | 'views';
|
||||
@@ -48,7 +48,7 @@ type TierFilter = 'all' | 'premium' | 'standard' | 'starter';
|
||||
type AvailabilityFilter = 'all' | 'main' | 'secondary';
|
||||
|
||||
interface SponsorLeaguesTemplateProps {
|
||||
data: {
|
||||
viewData: {
|
||||
leagues: AvailableLeague[];
|
||||
stats: {
|
||||
total: number;
|
||||
@@ -60,367 +60,170 @@ interface SponsorLeaguesTemplateProps {
|
||||
};
|
||||
}
|
||||
|
||||
function LeagueCard({ league, index }: { league: AvailableLeague; index: number }) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const tierConfig = {
|
||||
premium: {
|
||||
bg: 'bg-gradient-to-br from-yellow-500/10 to-amber-500/5',
|
||||
border: 'border-yellow-500/30',
|
||||
badge: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||
icon: '⭐'
|
||||
},
|
||||
standard: {
|
||||
bg: 'bg-gradient-to-br from-primary-blue/10 to-cyan-500/5',
|
||||
border: 'border-primary-blue/30',
|
||||
badge: 'bg-primary-blue/20 text-primary-blue border-primary-blue/30',
|
||||
icon: '🏆'
|
||||
},
|
||||
starter: {
|
||||
bg: 'bg-gradient-to-br from-gray-500/10 to-slate-500/5',
|
||||
border: 'border-gray-500/30',
|
||||
badge: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||
icon: '🚀'
|
||||
},
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
active: { color: 'text-performance-green', bg: 'bg-performance-green/10', label: 'Active Season' },
|
||||
upcoming: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', label: 'Starting Soon' },
|
||||
completed: { color: 'text-gray-400', bg: 'bg-gray-400/10', label: 'Season Ended' },
|
||||
};
|
||||
|
||||
const config = league.tierConfig;
|
||||
const status = league.statusConfig;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<Card className={`overflow-hidden border ${config.border} ${config.bg} hover:border-primary-blue/50 transition-all duration-300 h-full`}>
|
||||
<div className="p-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium capitalize border ${config.badge}`}>
|
||||
{config.icon} {league.tier}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${status.bg} ${status.color}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-white text-lg">{league.name}</h3>
|
||||
<p className="text-sm text-gray-500">{league.game}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-iron-gray/50 px-2 py-1 rounded">
|
||||
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
|
||||
<span className="text-sm font-medium text-white">{league.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-gray-400 mb-4 line-clamp-2">{league.description}</p>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||
<div className="text-center p-2 bg-iron-gray/50 rounded-lg">
|
||||
<div className="text-lg font-bold text-white">{league.drivers}</div>
|
||||
<div className="text-xs text-gray-500">Drivers</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-iron-gray/50 rounded-lg">
|
||||
<div className="text-lg font-bold text-white">{league.formattedAvgViews}</div>
|
||||
<div className="text-xs text-gray-500">Avg Views</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-iron-gray/50 rounded-lg">
|
||||
<div className="text-lg font-bold text-performance-green">{league.formattedCpm}</div>
|
||||
<div className="text-xs text-gray-500">CPM</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next Race */}
|
||||
{league.nextRace && (
|
||||
<div className="flex items-center gap-2 mb-4 text-sm">
|
||||
<Clock className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-400">Next:</span>
|
||||
<span className="text-white">{league.nextRace}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sponsorship Slots */}
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center justify-between p-2.5 bg-iron-gray/30 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${league.mainSponsorSlot.available ? 'bg-performance-green' : 'bg-racing-red'}`} />
|
||||
<span className="text-sm text-gray-300">Main Sponsor</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{league.mainSponsorSlot.available ? (
|
||||
<span className="text-white font-semibold">${league.mainSponsorSlot.price}/season</span>
|
||||
) : (
|
||||
<span className="text-gray-500 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" /> Filled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2.5 bg-iron-gray/30 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${league.secondarySlots.available > 0 ? 'bg-performance-green' : 'bg-racing-red'}`} />
|
||||
<span className="text-sm text-gray-300">Secondary Slots</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{league.secondarySlots.available > 0 ? (
|
||||
<span className="text-white font-semibold">
|
||||
{league.secondarySlots.available}/{league.secondarySlots.total} @ ${league.secondarySlots.price}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-500 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" /> Full
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/sponsor/leagues/${league.id}`} className="flex-1">
|
||||
<Button variant="secondary" className="w-full text-sm">
|
||||
View Details
|
||||
</Button>
|
||||
</Link>
|
||||
{(league.mainSponsorSlot.available || league.secondarySlots.available > 0) && (
|
||||
<Link href={`/sponsor/leagues/${league.id}?action=sponsor`} className="flex-1">
|
||||
<Button variant="primary" className="w-full text-sm">
|
||||
Sponsor
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SponsorLeaguesTemplate({ data }: SponsorLeaguesTemplateProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
export function SponsorLeaguesTemplate({ viewData }: SponsorLeaguesTemplateProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [tierFilter, setTierFilter] = useState<TierFilter>('all');
|
||||
const [availabilityFilter, setAvailabilityFilter] = useState<AvailabilityFilter>('all');
|
||||
const [sortBy, setSortBy] = useState<SortOption>('rating');
|
||||
|
||||
// Filter and sort leagues
|
||||
const filteredLeagues = data.leagues
|
||||
.filter((league: any) => {
|
||||
if (searchQuery && !league.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (tierFilter !== 'all' && league.tier !== tierFilter) {
|
||||
return false;
|
||||
}
|
||||
if (availabilityFilter === 'main' && !league.mainSponsorSlot.available) {
|
||||
return false;
|
||||
}
|
||||
if (availabilityFilter === 'secondary' && league.secondarySlots.available === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a: any, b: any) => {
|
||||
switch (sortBy) {
|
||||
case 'rating': return b.rating - a.rating;
|
||||
case 'drivers': return b.drivers - a.drivers;
|
||||
case 'price': return a.mainSponsorSlot.price - b.mainSponsorSlot.price;
|
||||
case 'views': return b.avgViewsPerRace - a.avgViewsPerRace;
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
const filteredLeagues = useMemo(() => {
|
||||
return viewData.leagues
|
||||
.filter((league) => {
|
||||
if (searchQuery && !league.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (tierFilter !== 'all' && league.tier !== tierFilter) {
|
||||
return false;
|
||||
}
|
||||
if (availabilityFilter === 'main' && !league.mainSponsorSlot.available) {
|
||||
return false;
|
||||
}
|
||||
if (availabilityFilter === 'secondary' && league.secondarySlots.available === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'rating': return b.rating - a.rating;
|
||||
case 'drivers': return b.drivers - a.drivers;
|
||||
case 'price': return a.mainSponsorSlot.price - b.mainSponsorSlot.price;
|
||||
case 'views': return b.avgViewsPerRace - a.avgViewsPerRace;
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
}, [viewData.leagues, searchQuery, tierFilter, availabilityFilter, sortBy]);
|
||||
|
||||
const stats = data.stats;
|
||||
const stats = viewData.stats;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-8 px-4">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 mb-6">
|
||||
<Link href=routes.sponsor.dashboard className="hover:text-white transition-colors">Dashboard</Link>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<span className="text-white">Browse Leagues</span>
|
||||
</div>
|
||||
<Container size="lg" py={8}>
|
||||
<Stack gap={8}>
|
||||
{/* Breadcrumb */}
|
||||
<Box>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Link href={routes.sponsor.dashboard}>
|
||||
<Text size="sm" color="text-gray-400">Dashboard</Text>
|
||||
</Link>
|
||||
<Text size="sm" color="text-gray-500">/</Text>
|
||||
<Text size="sm" color="text-white">Browse Leagues</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-2 flex items-center gap-3">
|
||||
<Trophy className="w-7 h-7 text-primary-blue" />
|
||||
League Sponsorship Marketplace
|
||||
</h1>
|
||||
<p className="text-gray-400">
|
||||
Discover racing leagues looking for sponsors. All prices shown exclude VAT.
|
||||
</p>
|
||||
</div>
|
||||
{/* 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>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-white">{stats.total}</div>
|
||||
<div className="text-sm text-gray-400">Leagues</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-performance-green">{stats.mainAvailable}</div>
|
||||
<div className="text-sm text-gray-400">Main Slots</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-primary-blue">{stats.secondaryAvailable}</div>
|
||||
<div className="text-sm text-gray-400">Secondary Slots</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-white">{stats.totalDrivers}</div>
|
||||
<div className="text-sm text-gray-400">Total Drivers</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-warning-amber">${stats.avgCpm}</div>
|
||||
<div className="text-sm text-gray-400">Avg CPM</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
{/* Stats Overview */}
|
||||
<Grid cols={5} gap={4}>
|
||||
<StatCard label="Leagues" value={stats.total} />
|
||||
<StatCard label="Main Slots" value={stats.mainAvailable} color="text-performance-green" />
|
||||
<StatCard label="Secondary Slots" value={stats.secondaryAvailable} color="text-primary-blue" />
|
||||
<StatCard label="Total Drivers" value={stats.totalDrivers} />
|
||||
<StatCard label="Avg CPM" value={`$${stats.avgCpm}`} color="text-warning-amber" />
|
||||
</Grid>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col lg:flex-row gap-4 mb-6">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search leagues..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white placeholder-gray-500 focus:border-primary-blue focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tier Filter */}
|
||||
<select
|
||||
value={tierFilter}
|
||||
onChange={(e) => setTierFilter(e.target.value as TierFilter)}
|
||||
className="px-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
|
||||
>
|
||||
<option value="all">All Tiers</option>
|
||||
<option value="premium">⭐ Premium</option>
|
||||
<option value="standard">🏆 Standard</option>
|
||||
<option value="starter">🚀 Starter</option>
|
||||
</select>
|
||||
|
||||
{/* Availability Filter */}
|
||||
<select
|
||||
value={availabilityFilter}
|
||||
onChange={(e) => setAvailabilityFilter(e.target.value as AvailabilityFilter)}
|
||||
className="px-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
|
||||
>
|
||||
<option value="all">All Slots</option>
|
||||
<option value="main">Main Available</option>
|
||||
<option value="secondary">Secondary Available</option>
|
||||
</select>
|
||||
|
||||
{/* Sort */}
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||
className="px-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
|
||||
>
|
||||
<option value="rating">Sort by Rating</option>
|
||||
<option value="drivers">Sort by Drivers</option>
|
||||
<option value="views">Sort by Views</option>
|
||||
<option value="price">Sort by Price</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Results Count */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<p className="text-sm text-gray-400">
|
||||
Showing {filteredLeagues.length} of {data.leagues.length} leagues
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/teams">
|
||||
<Button variant="secondary" className="text-sm">
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
Browse Teams
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/drivers">
|
||||
<Button variant="secondary" className="text-sm">
|
||||
<Car className="w-4 h-4 mr-2" />
|
||||
Browse Drivers
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* League Grid */}
|
||||
{filteredLeagues.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredLeagues.map((league: any, index: number) => (
|
||||
<LeagueCard key={league.id} league={league} index={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="text-center py-16">
|
||||
<Trophy className="w-12 h-12 text-gray-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-white mb-2">No leagues found</h3>
|
||||
<p className="text-gray-400 mb-6">Try adjusting your filters to see more results</p>
|
||||
<Button variant="secondary" onClick={() => {
|
||||
setSearchQuery('');
|
||||
setTierFilter('all');
|
||||
setAvailabilityFilter('all');
|
||||
}}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
{/* Filters (Simplified for template) */}
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" color="text-gray-400">
|
||||
Use the search and filter options to find the perfect league for your brand.
|
||||
</Text>
|
||||
<Grid cols={4} gap={4}>
|
||||
<Box>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search leagues..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-lg border border-charcoal-outline bg-iron-gray text-white placeholder-gray-500 focus:border-primary-blue focus:outline-none"
|
||||
/>
|
||||
</Box>
|
||||
{/* Selects would go here, using standard Select UI if available */}
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Platform Fee Notice */}
|
||||
<div className="mt-8 rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Megaphone className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-300 font-medium mb-1">Platform Fee</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
A {siteConfig.fees.platformFeePercent}% platform fee applies to all sponsorship payments. {siteConfig.fees.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Results Count */}
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Text size="sm" color="text-gray-400">
|
||||
Showing {filteredLeagues.length} of {viewData.leagues.length} leagues
|
||||
</Text>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Link href="/teams">
|
||||
<Button variant="secondary" size="sm" icon={<Icon icon={Users} size={4} />}>
|
||||
Browse Teams
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/drivers">
|
||||
<Button variant="secondary" size="sm" icon={<Icon icon={Car} size={4} />}>
|
||||
Browse Drivers
|
||||
</Button>
|
||||
</Link>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* League Grid */}
|
||||
{filteredLeagues.length > 0 ? (
|
||||
<Grid cols={3} gap={6}>
|
||||
{filteredLeagues.map((league) => (
|
||||
<GridItem key={league.id} colSpan={12} mdSpan={6} lgSpan={4}>
|
||||
<AvailableLeagueCard league={league as any} />
|
||||
</GridItem>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Card>
|
||||
<Stack align="center" py={16} gap={4}>
|
||||
<Surface variant="muted" rounded="full" padding={4}>
|
||||
<Icon icon={Trophy} size={12} color="#525252" />
|
||||
</Surface>
|
||||
<Box style={{ textAlign: 'center' }}>
|
||||
<Heading level={3}>No leagues found</Heading>
|
||||
<Text color="text-gray-400" block mt={2}>Try adjusting your filters to see more results</Text>
|
||||
</Box>
|
||||
<Button variant="secondary" onClick={() => {
|
||||
setSearchQuery('');
|
||||
setTierFilter('all');
|
||||
setAvailabilityFilter('all');
|
||||
}}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Platform Fee Notice */}
|
||||
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(38, 38, 38, 0.3)' }}>
|
||||
<Stack direction="row" align="start" gap={3}>
|
||||
<Icon icon={Megaphone} size={5} color="#3b82f6" />
|
||||
<Box>
|
||||
<Text size="sm" color="text-gray-300" weight="medium" block mb={1}>Platform Fee</Text>
|
||||
<Text size="xs" color="text-gray-500">
|
||||
A {siteConfig.fees.platformFeePercent}% platform fee applies to all sponsorship payments. {siteConfig.fees.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Surface>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function StatCard({ label, value, color = 'text-white' }: { label: string, value: string | number, color?: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<Box style={{ textAlign: 'center' }}>
|
||||
<Text size="2xl" weight="bold" color={color as any} block mb={1}>{value}</Text>
|
||||
<Text size="sm" color="text-gray-400">{label}</Text>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Container } from '@/ui/Container';
|
||||
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 type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Container from '@/components/ui/Container';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
|
||||
export interface SponsorshipRequestsTemplateProps {
|
||||
viewData: SponsorshipRequestsViewData;
|
||||
@@ -16,65 +23,72 @@ export function SponsorshipRequestsTemplate({
|
||||
onReject,
|
||||
}: SponsorshipRequestsTemplateProps) {
|
||||
return (
|
||||
<Container size="md" className="space-y-8">
|
||||
<div>
|
||||
<Heading level={1} className="text-white mb-2">
|
||||
Sponsorship Requests
|
||||
</Heading>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Manage pending sponsorship requests for your profile.
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{viewData.sections.map((section) => (
|
||||
<Card key={`${section.entityType}-${section.entityId}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Heading level={2} className="text-white">
|
||||
{section.entityName}
|
||||
</Heading>
|
||||
<span className="text-xs text-gray-400">
|
||||
{section.requests.length} {section.requests.length === 1 ? 'request' : 'requests'}
|
||||
</span>
|
||||
</div>
|
||||
{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 ? (
|
||||
<p className="text-sm text-gray-400">No pending requests.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{section.requests.map((request) => (
|
||||
<div
|
||||
key={request.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-white font-medium">{request.sponsorName}</p>
|
||||
{request.message && (
|
||||
<p className="text-xs text-gray-400 mt-1">{request.message}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{new Date(request.createdAtIso).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => onAccept(request.id)}
|
||||
{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}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onReject(request.id)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
<Stack direction="row" align="center" justify="between" wrap gap={4}>
|
||||
<Box style={{ flex: 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}>
|
||||
{new Date(request.createdAtIso).toLocaleDateString()}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
/* eslint-disable gridpilot-rules/no-raw-html-in-app */
|
||||
'use client';
|
||||
|
||||
import { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData';
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Section } from '@/ui/Section';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Flag, AlertCircle, Calendar, MapPin, Gavel } from 'lucide-react';
|
||||
import type { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData';
|
||||
|
||||
interface StewardingTemplateProps {
|
||||
viewData: StewardingViewData;
|
||||
@@ -11,146 +18,166 @@ interface StewardingTemplateProps {
|
||||
|
||||
export function StewardingTemplate({ viewData }: StewardingTemplateProps) {
|
||||
return (
|
||||
<Section>
|
||||
<Stack gap={6}>
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Stewarding</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
<Stack gap={6}>
|
||||
<Box>
|
||||
<Heading level={1}>Stewarding</Heading>
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>
|
||||
Quick overview of protests and penalties across all races
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Stats summary */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline text-center">
|
||||
<div className="text-2xl font-bold text-warning-amber">{viewData.totalPending}</div>
|
||||
<div className="text-sm text-gray-400">Pending</div>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline text-center">
|
||||
<div className="text-2xl font-bold text-performance-green">{viewData.totalResolved}</div>
|
||||
<div className="text-sm text-gray-400">Resolved</div>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline text-center">
|
||||
<div className="text-2xl font-bold text-red-400">{viewData.totalPenalties}</div>
|
||||
<div className="text-sm text-gray-400">Penalties</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Stats summary */}
|
||||
<Grid cols={3} gap={4}>
|
||||
<StatItem label="Pending" value={viewData.totalPending} color="#f59e0b" />
|
||||
<StatItem label="Resolved" value={viewData.totalResolved} color="#10b981" />
|
||||
<StatItem label="Penalties" value={viewData.totalPenalties} color="#ef4444" />
|
||||
</Grid>
|
||||
|
||||
{/* Content */}
|
||||
{viewData.races.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
|
||||
<Flag className="w-8 h-8 text-performance-green" />
|
||||
</div>
|
||||
<p className="font-semibold text-lg text-white mb-2">All Clear!</p>
|
||||
<p className="text-sm text-gray-400">No protests or penalties to review.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{viewData.races.map((race) => (
|
||||
<div key={race.id} className="rounded-lg border border-charcoal-outline overflow-hidden">
|
||||
{/* Race Header */}
|
||||
<div className="px-4 py-3 bg-iron-gray/30">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-gray-400" />
|
||||
<span className="font-medium text-white">{race.track}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{new Date(race.scheduledAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
|
||||
{race.pendingProtests.length} pending
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
{viewData.races.length === 0 ? (
|
||||
<Stack align="center" py={12} gap={4}>
|
||||
<Surface variant="muted" rounded="full" padding={4}>
|
||||
<Icon icon={Flag} size={8} color="#10b981" />
|
||||
</Surface>
|
||||
<Box style={{ textAlign: 'center' }}>
|
||||
<Text weight="semibold" size="lg" color="text-white" block mb={1}>All Clear!</Text>
|
||||
<Text size="sm" color="text-gray-400">No protests or penalties to review.</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={4}>
|
||||
{viewData.races.map((race) => (
|
||||
<Surface
|
||||
key={race.id}
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
border
|
||||
style={{ overflow: 'hidden', borderColor: '#262626' }}
|
||||
>
|
||||
{/* Race Header */}
|
||||
<Box p={4} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)', borderBottom: '1px solid #262626' }}>
|
||||
<Stack direction="row" align="center" gap={4} wrap>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={MapPin} size={4} color="#9ca3af" />
|
||||
<Text weight="medium" color="text-white">{race.track}</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Calendar} size={4} color="#9ca3af" />
|
||||
<Text size="sm" color="text-gray-400">{new Date(race.scheduledAt).toLocaleDateString()}</Text>
|
||||
</Stack>
|
||||
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)', paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
|
||||
<Text size="xs" weight="medium" color="text-warning-amber">{race.pendingProtests.length} pending</Text>
|
||||
</Surface>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Race Content */}
|
||||
<div className="p-4 space-y-3 bg-deep-graphite/50">
|
||||
{race.pendingProtests.length === 0 && race.resolvedProtests.length === 0 && race.penalties.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 text-center py-4">No items to display</p>
|
||||
) : (
|
||||
<>
|
||||
{race.pendingProtests.map((protest) => {
|
||||
const protester = viewData.drivers.find(d => d.id === protest.protestingDriverId);
|
||||
const accused = viewData.drivers.find(d => d.id === protest.accusedDriverId);
|
||||
{/* Race Content */}
|
||||
<Box p={4}>
|
||||
{race.pendingProtests.length === 0 && race.resolvedProtests.length === 0 && race.penalties.length === 0 ? (
|
||||
<Box py={4}>
|
||||
<Text size="sm" color="text-gray-400" block style={{ textAlign: 'center' }}>No items to display</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack gap={3}>
|
||||
{race.pendingProtests.map((protest) => {
|
||||
const protester = viewData.drivers.find(d => d.id === protest.protestingDriverId);
|
||||
const accused = viewData.drivers.find(d => d.id === protest.accusedDriverId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={protest.id}
|
||||
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" />
|
||||
<span className="font-medium text-white">
|
||||
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">Pending</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
|
||||
<span>Lap {protest.incident.lap}</span>
|
||||
<span>•</span>
|
||||
<span>Filed {new Date(protest.filedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300 line-clamp-2">
|
||||
{protest.incident.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
Review needed
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<Surface
|
||||
key={protest.id}
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
border
|
||||
padding={4}
|
||||
style={{ backgroundColor: 'rgba(38, 38, 38, 0.3)', borderColor: '#262626' }}
|
||||
>
|
||||
<Stack direction="row" align="start" justify="between" gap={4}>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" align="center" gap={2} mb={2} wrap>
|
||||
<Icon icon={AlertCircle} size={4} color="#f59e0b" />
|
||||
<Text weight="medium" color="text-white">
|
||||
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
|
||||
</Text>
|
||||
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)', paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
|
||||
<Text size="xs" weight="medium" color="text-warning-amber">Pending</Text>
|
||||
</Surface>
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={4} mb={2}>
|
||||
<Text size="sm" color="text-gray-400">Lap {protest.incident.lap}</Text>
|
||||
<Text size="sm" color="text-gray-400">•</Text>
|
||||
<Text size="sm" color="text-gray-400">Filed {new Date(protest.filedAt).toLocaleDateString()}</Text>
|
||||
</Stack>
|
||||
<Text size="sm" color="text-gray-300" block truncate>{protest.incident.description}</Text>
|
||||
</Box>
|
||||
<Text size="sm" color="text-gray-500">Review needed</Text>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
})}
|
||||
|
||||
{race.penalties.map((penalty) => {
|
||||
const driver = viewData.drivers.find(d => d.id === penalty.driverId);
|
||||
return (
|
||||
<div
|
||||
key={penalty.id}
|
||||
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Gavel className="w-4 h-4 text-red-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white">{driver?.name || 'Unknown'}</span>
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
|
||||
{penalty.type.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{penalty.reason}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-lg font-bold text-red-400">
|
||||
{penalty.type === 'time_penalty' && `+${penalty.value}s`}
|
||||
{penalty.type === 'grid_penalty' && `+${penalty.value} grid`}
|
||||
{penalty.type === 'points_deduction' && `-${penalty.value} pts`}
|
||||
{penalty.type === 'disqualification' && 'DSQ'}
|
||||
{penalty.type === 'warning' && 'Warning'}
|
||||
{penalty.type === 'license_points' && `${penalty.value} LP`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{race.penalties.map((penalty) => {
|
||||
const driver = viewData.drivers.find(d => d.id === penalty.driverId);
|
||||
return (
|
||||
<Surface
|
||||
key={penalty.id}
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
border
|
||||
padding={4}
|
||||
style={{ backgroundColor: 'rgba(38, 38, 38, 0.3)', borderColor: '#262626' }}
|
||||
>
|
||||
<Stack direction="row" align="center" justify="between" gap={4}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Surface variant="muted" rounded="full" padding={2} style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)' }}>
|
||||
<Icon icon={Gavel} size={4} color="#ef4444" />
|
||||
</Surface>
|
||||
<Box>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text weight="medium" color="text-white">{driver?.name || 'Unknown'}</Text>
|
||||
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
|
||||
<Text size="xs" weight="medium" color="text-error-red" style={{ textTransform: 'capitalize' }}>
|
||||
{penalty.type.replace('_', ' ')}
|
||||
</Text>
|
||||
</Surface>
|
||||
</Stack>
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>{penalty.reason}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box style={{ textAlign: 'right' }}>
|
||||
<Text weight="bold" color="text-error-red" style={{ fontSize: '1.125rem' }}>
|
||||
{penalty.type === 'time_penalty' && `+${penalty.value}s`}
|
||||
{penalty.type === 'grid_penalty' && `+${penalty.value} grid`}
|
||||
{penalty.type === 'points_deduction' && `-${penalty.value} pts`}
|
||||
{penalty.type === 'disqualification' && 'DSQ'}
|
||||
{penalty.type === 'warning' && 'Warning'}
|
||||
{penalty.type === 'license_points' && `${penalty.value} LP`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Surface>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function StatItem({ label, value, color }: { label: string, value: string | number, color: string }) {
|
||||
return (
|
||||
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)', borderColor: '#262626', textAlign: 'center' }}>
|
||||
<Text size="2xl" weight="bold" style={{ color }}>{value}</Text>
|
||||
<Text size="sm" color="text-gray-500" block mt={1}>{label}</Text>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import SponsorInsightsCard from '@/components/sponsors/SponsorInsightsCard';
|
||||
import { SlotTemplates } from '@/components/sponsors/SlotTemplates';
|
||||
import { useSponsorMode } from '@/components/sponsors/useSponsorMode';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Image from 'next/image';
|
||||
import SponsorInsightsCard from '@/components/sponsors/SponsorInsightsCard';
|
||||
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';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
import JoinTeamButton from '@/components/teams/JoinTeamButton';
|
||||
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 StatItem from '@/components/teams/StatItem';
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
import PlaceholderImage from '@/components/ui/PlaceholderImage';
|
||||
import type { TeamDetailViewData } from '@/lib/view-data/TeamDetailViewData';
|
||||
|
||||
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
|
||||
|
||||
// ============================================================================
|
||||
// TEMPLATE PROPS
|
||||
// ============================================================================
|
||||
|
||||
export interface TeamDetailTemplateProps {
|
||||
viewData: TeamDetailViewData;
|
||||
activeTab: Tab;
|
||||
@@ -36,10 +35,6 @@ export interface TeamDetailTemplateProps {
|
||||
onGoBack: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN TEMPLATE COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function TeamDetailTemplate({
|
||||
viewData,
|
||||
activeTab,
|
||||
@@ -55,28 +50,32 @@ export function TeamDetailTemplate({
|
||||
// Show loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center text-gray-400">Loading team...</div>
|
||||
</div>
|
||||
<Container size="lg" py={12}>
|
||||
<Stack align="center">
|
||||
<Text color="text-gray-400">Loading team...</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// Show not found state
|
||||
if (!viewData.team) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<Container size="md" py={12}>
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Team Not Found</h2>
|
||||
<p className="text-gray-400 mb-6">
|
||||
The team you're looking for doesn't exist or has been disbanded.
|
||||
</p>
|
||||
<Stack align="center" py={12} gap={6}>
|
||||
<Box style={{ 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>
|
||||
<Button variant="primary" onClick={onGoBack}>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,164 +89,128 @@ export function TeamDetailTemplate({
|
||||
const visibleTabs = tabs.filter(tab => tab.visible);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Teams', href: '/teams' },
|
||||
{ label: viewData.team.name }
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Sponsor Insights Card - Consistent placement at top */}
|
||||
{isSponsorMode && viewData.team && (
|
||||
<SponsorInsightsCard
|
||||
entityType="team"
|
||||
entityId={viewData.team.id}
|
||||
entityName={viewData.team.name}
|
||||
tier="standard"
|
||||
metrics={viewData.teamMetrics}
|
||||
slots={SlotTemplates.team(true, true, 500, 250)}
|
||||
trustScore={90}
|
||||
monthlyActivity={85}
|
||||
<Container size="lg" py={8}>
|
||||
<Stack gap={6}>
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Teams', href: '/teams' },
|
||||
{ label: viewData.team.name }
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card className="mb-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-6">
|
||||
<div className="w-24 h-24 bg-charcoal-outline rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||
<Image
|
||||
src={getMediaUrl('team-logo', viewData.team.id)}
|
||||
alt={viewData.team.name}
|
||||
width={96}
|
||||
height={96}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl font-bold text-white">{viewData.team.name}</h1>
|
||||
{viewData.team.tag && (
|
||||
<span className="px-2 py-0.5 rounded-full text-xs bg-charcoal-outline text-gray-300">
|
||||
[{viewData.team.tag}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-gray-300 mb-4 max-w-2xl">{viewData.team.description}</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400">
|
||||
<span>{viewData.memberships.length} {viewData.memberships.length === 1 ? 'member' : 'members'}</span>
|
||||
{viewData.team.category && (
|
||||
<span className="flex items-center gap-1 text-purple-400">
|
||||
<span className="w-2 h-2 rounded-full bg-purple-400"></span>
|
||||
{viewData.team.category}
|
||||
</span>
|
||||
)}
|
||||
{viewData.team.createdAt && (
|
||||
<span>
|
||||
Founded {new Date(viewData.team.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
||||
</span>
|
||||
)}
|
||||
{viewData.team.leagues && viewData.team.leagues.length > 0 && (
|
||||
<span>
|
||||
Active in {viewData.team.leagues.length} {viewData.team.leagues.length === 1 ? 'league' : 'leagues'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JoinTeamButton teamId={viewData.team.id} onUpdate={onUpdate} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 border-b border-charcoal-outline">
|
||||
{visibleTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`
|
||||
px-4 py-3 font-medium transition-all relative
|
||||
${activeTab === tab.id
|
||||
? 'text-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tab.label}
|
||||
{activeTab === tab.id && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-blue" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-2">
|
||||
<h3 className="text-xl font-semibold text-white mb-4">About</h3>
|
||||
<p className="text-gray-300 leading-relaxed">{viewData.team.description}</p>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-xl font-semibold text-white mb-4">Quick Stats</h3>
|
||||
<div className="space-y-3">
|
||||
<StatItem label="Members" value={viewData.memberships.length.toString()} color="text-primary-blue" />
|
||||
{viewData.team.category && (
|
||||
<StatItem label="Category" value={viewData.team.category} color="text-purple-400" />
|
||||
)}
|
||||
{viewData.team.leagues && viewData.team.leagues.length > 0 && (
|
||||
<StatItem label="Leagues" value={viewData.team.leagues.length.toString()} color="text-green-400" />
|
||||
)}
|
||||
{viewData.team.createdAt && (
|
||||
<StatItem
|
||||
label="Founded"
|
||||
value={new Date(viewData.team.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
color="text-gray-300"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-xl font-semibold text-white mb-4">Recent Activity</h3>
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
No recent activity to display
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'roster' && (
|
||||
<TeamRoster
|
||||
teamId={viewData.team.id}
|
||||
memberships={viewData.memberships}
|
||||
isAdmin={viewData.isAdmin}
|
||||
onRemoveMember={onRemoveMember}
|
||||
onChangeRole={onChangeRole}
|
||||
{/* Sponsor Insights Card */}
|
||||
{isSponsorMode && viewData.team && (
|
||||
<SponsorInsightsCard
|
||||
entityType="team"
|
||||
entityId={viewData.team.id}
|
||||
entityName={viewData.team.name}
|
||||
tier="standard"
|
||||
metrics={viewData.teamMetrics}
|
||||
slots={SlotTemplates.team(true, true, 500, 250)}
|
||||
trustScore={90}
|
||||
monthlyActivity={85}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'standings' && (
|
||||
<TeamStandings teamId={viewData.team.id} leagues={viewData.team.leagues} />
|
||||
)}
|
||||
<TeamHero
|
||||
team={viewData.team}
|
||||
memberCount={viewData.memberships.length}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
|
||||
{activeTab === 'admin' && viewData.isAdmin && (
|
||||
<TeamAdmin team={viewData.team} onUpdate={onUpdate} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Tabs */}
|
||||
<Box style={{ borderBottom: '1px solid #262626' }}>
|
||||
<Stack direction="row" gap={6}>
|
||||
{visibleTabs.map((tab) => (
|
||||
<Box
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
pb={3}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderBottom: activeTab === tab.id ? '2px solid #3b82f6' : '2px solid transparent',
|
||||
color: activeTab === tab.id ? '#3b82f6' : '#9ca3af'
|
||||
}}
|
||||
>
|
||||
<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" style={{ lineHeight: 1.625 }}>{viewData.team.description}</Text>
|
||||
</Card>
|
||||
</GridItem>
|
||||
|
||||
<GridItem colSpan={12} lgSpan={4}>
|
||||
<Card>
|
||||
<Box mb={4}>
|
||||
<Heading level={2}>Quick Stats</Heading>
|
||||
</Box>
|
||||
<Stack gap={3}>
|
||||
<StatItem label="Members" value={viewData.memberships.length.toString()} color="text-primary-blue" />
|
||||
{viewData.team.category && (
|
||||
<StatItem label="Category" value={viewData.team.category} color="text-purple-400" />
|
||||
)}
|
||||
{viewData.team.leagues && viewData.team.leagues.length > 0 && (
|
||||
<StatItem label="Leagues" value={viewData.team.leagues.length.toString()} color="text-green-400" />
|
||||
)}
|
||||
{viewData.team.createdAt && (
|
||||
<StatItem
|
||||
label="Founded"
|
||||
value={new Date(viewData.team.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
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 style={{ textAlign: 'center' }}>No recent activity to display</Text>
|
||||
</Box>
|
||||
</Card>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{activeTab === 'roster' && (
|
||||
<TeamRoster
|
||||
teamId={viewData.team.id}
|
||||
memberships={viewData.memberships}
|
||||
isAdmin={viewData.isAdmin}
|
||||
onRemoveMember={onRemoveMember}
|
||||
onChangeRole={onChangeRole}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'standings' && (
|
||||
<TeamStandings teamId={viewData.team.id} leagues={viewData.team.leagues} />
|
||||
)}
|
||||
|
||||
{activeTab === 'admin' && viewData.isAdmin && (
|
||||
<TeamAdmin team={viewData.team} onUpdate={onUpdate} />
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Users, Trophy, Crown, Award, ArrowLeft, Medal, Target, Globe, Languages } from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Award, 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 { Container } from '@/ui/Container';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import TopThreePodium from '@/components/teams/TopThreePodium';
|
||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
import TeamRankingsFilter from '@/components/TeamRankingsFilter';
|
||||
import Image from 'next/image';
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
import { TeamRankingsTable } from '@/components/teams/TeamRankingsTable';
|
||||
|
||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
|
||||
@@ -30,56 +30,6 @@ interface TeamLeaderboardTemplateProps {
|
||||
onBackToTeams: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
const getSafeRating = (team: TeamSummaryViewModel): number => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
const getSafeTotalWins = (team: TeamSummaryViewModel): number => {
|
||||
const raw = team.totalWins;
|
||||
const value = typeof raw === 'number' ? raw : 0;
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
};
|
||||
|
||||
const getSafeTotalRaces = (team: TeamSummaryViewModel): number => {
|
||||
const raw = team.totalRaces;
|
||||
const value = typeof raw === 'number' ? raw : 0;
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
};
|
||||
|
||||
const getMedalColor = (position: number) => {
|
||||
switch (position) {
|
||||
case 0:
|
||||
return 'text-yellow-400';
|
||||
case 1:
|
||||
return 'text-gray-300';
|
||||
case 2:
|
||||
return 'text-amber-600';
|
||||
default:
|
||||
return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getMedalBg = (position: number) => {
|
||||
switch (position) {
|
||||
case 0:
|
||||
return 'bg-gradient-to-br from-yellow-400/20 to-yellow-600/10 border-yellow-400/40';
|
||||
case 1:
|
||||
return 'bg-gradient-to-br from-gray-300/20 to-gray-400/10 border-gray-300/40';
|
||||
case 2:
|
||||
return 'bg-gradient-to-br from-amber-600/20 to-amber-700/10 border-amber-600/40';
|
||||
default:
|
||||
return 'bg-iron-gray/50 border-charcoal-outline';
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// MAIN TEMPLATE COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export default function TeamLeaderboardTemplate({
|
||||
teams,
|
||||
searchQuery,
|
||||
@@ -92,283 +42,78 @@ export default function TeamLeaderboardTemplate({
|
||||
onBackToTeams,
|
||||
}: TeamLeaderboardTemplateProps) {
|
||||
// Filter and sort teams
|
||||
const filteredAndSortedTeams = teams
|
||||
.filter((team) => {
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
if (!team.name.toLowerCase().includes(query) && !(team.description ?? '').toLowerCase().includes(query)) {
|
||||
const filteredAndSortedTeams = useMemo(() => {
|
||||
return teams
|
||||
.filter((team) => {
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
if (!team.name.toLowerCase().includes(query) && !(team.description ?? '').toLowerCase().includes(query)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (filterLevel !== 'all' && team.performanceLevel !== filterLevel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Level filter
|
||||
if (filterLevel !== 'all' && team.performanceLevel !== filterLevel) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'rating': {
|
||||
const aRating = getSafeRating(a);
|
||||
const bRating = getSafeRating(b);
|
||||
return bRating - aRating;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'rating': return 0; // Placeholder
|
||||
case 'wins': return (b.totalWins || 0) - (a.totalWins || 0);
|
||||
case 'races': return (b.totalRaces || 0) - (a.totalRaces || 0);
|
||||
default: return 0;
|
||||
}
|
||||
case 'wins': {
|
||||
const aWinsSort = getSafeTotalWins(a);
|
||||
const bWinsSort = getSafeTotalWins(b);
|
||||
return bWinsSort - aWinsSort;
|
||||
}
|
||||
case 'winRate': {
|
||||
const aRaces = getSafeTotalRaces(a);
|
||||
const bRaces = getSafeTotalRaces(b);
|
||||
const aWins = getSafeTotalWins(a);
|
||||
const bWins = getSafeTotalWins(b);
|
||||
const aRate = aRaces > 0 ? aWins / aRaces : 0;
|
||||
const bRate = bRaces > 0 ? bWins / bRaces : 0;
|
||||
return bRate - aRate;
|
||||
}
|
||||
case 'races': {
|
||||
const aRacesSort = getSafeTotalRaces(a);
|
||||
const bRacesSort = getSafeTotalRaces(b);
|
||||
return bRacesSort - aRacesSort;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [teams, searchQuery, filterLevel, sortBy]);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 pb-12">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBackToTeams}
|
||||
className="flex items-center gap-2 mb-6"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Teams
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-yellow-400/20 to-amber-600/10 border border-yellow-400/30">
|
||||
<Award className="w-7 h-7 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<Heading level={1} className="text-3xl lg:text-4xl">
|
||||
Team Leaderboard
|
||||
</Heading>
|
||||
<p className="text-gray-400">Rankings of all teams by performance metrics</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<TeamRankingsFilter
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={onSearchChange}
|
||||
filterLevel={filterLevel}
|
||||
onFilterLevelChange={onFilterLevelChange}
|
||||
sortBy={sortBy}
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
|
||||
{/* Podium for Top 3 - only show when viewing by rating without filters */}
|
||||
{sortBy === 'rating' && filterLevel === 'all' && !searchQuery && filteredAndSortedTeams.length >= 3 && (
|
||||
<TopThreePodium teams={filteredAndSortedTeams} onClick={onTeamClick} />
|
||||
)}
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Users className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-xs text-gray-500">Total Teams</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{filteredAndSortedTeams.length}</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Crown className="w-4 h-4 text-yellow-400" />
|
||||
<span className="text-xs text-gray-500">Pro Teams</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{filteredAndSortedTeams.filter((t) => t.performanceLevel === 'pro').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Trophy className="w-4 h-4 text-performance-green" />
|
||||
<span className="text-xs text-gray-500">Total Wins</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{filteredAndSortedTeams.reduce<number>(
|
||||
(sum, t) => sum + getSafeTotalWins(t),
|
||||
0,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Target className="w-4 h-4 text-neon-aqua" />
|
||||
<span className="text-xs text-gray-500">Total Races</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{filteredAndSortedTeams.reduce<number>(
|
||||
(sum, t) => sum + getSafeTotalRaces(t),
|
||||
0,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leaderboard Table */}
|
||||
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
|
||||
{/* Table Header */}
|
||||
<div className="grid grid-cols-12 gap-4 px-4 py-3 bg-iron-gray/50 border-b border-charcoal-outline text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<div className="col-span-1 text-center">Rank</div>
|
||||
<div className="col-span-4 lg:col-span-5">Team</div>
|
||||
<div className="col-span-2 text-center hidden lg:block">Members</div>
|
||||
<div className="col-span-2 lg:col-span-1 text-center">Rating</div>
|
||||
<div className="col-span-2 lg:col-span-1 text-center">Wins</div>
|
||||
<div className="col-span-2 text-center">Win Rate</div>
|
||||
</div>
|
||||
|
||||
{/* Table Body */}
|
||||
<div className="divide-y divide-charcoal-outline/50">
|
||||
{filteredAndSortedTeams.map((team, index) => {
|
||||
const levelConfig = ['beginner', 'intermediate', 'advanced', 'pro'].find((l) => l === team.performanceLevel);
|
||||
const LevelIcon = levelConfig === 'pro' ? Crown : levelConfig === 'advanced' ? Crown : levelConfig === 'intermediate' ? Crown : () => null;
|
||||
const totalRaces = getSafeTotalRaces(team);
|
||||
const totalWins = getSafeTotalWins(team);
|
||||
const winRate =
|
||||
totalRaces > 0 ? ((totalWins / totalRaces) * 100).toFixed(1) : '0.0';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={team.id}
|
||||
type="button"
|
||||
onClick={() => onTeamClick(team.id)}
|
||||
className="grid grid-cols-12 gap-4 px-4 py-4 w-full text-left hover:bg-iron-gray/30 transition-colors group"
|
||||
>
|
||||
{/* Position */}
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-bold border ${getMedalBg(index)} ${getMedalColor(index)}`}
|
||||
>
|
||||
{index < 3 ? (
|
||||
<Medal className="w-4 h-4" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Info */}
|
||||
<div className="col-span-4 lg:col-span-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-charcoal-outline border border-charcoal-outline overflow-hidden">
|
||||
<Image
|
||||
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
|
||||
alt={team.name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-white font-semibold truncate group-hover:text-purple-400 transition-colors">
|
||||
{team.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 flex-wrap">
|
||||
<span className={`${team.performanceLevel === 'pro' ? 'text-yellow-400' : team.performanceLevel === 'advanced' ? 'text-purple-400' : team.performanceLevel === 'intermediate' ? 'text-primary-blue' : 'text-green-400'}`}>
|
||||
{team.performanceLevel}
|
||||
</span>
|
||||
{team.category && (
|
||||
<span className="flex items-center gap-1 text-purple-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
|
||||
{team.category}
|
||||
</span>
|
||||
)}
|
||||
{team.region && (
|
||||
<span className="flex items-center gap-1 text-gray-400">
|
||||
<Globe className="w-3 h-3 text-neon-aqua" />
|
||||
{team.region}
|
||||
</span>
|
||||
)}
|
||||
{team.languages && team.languages.length > 0 && (
|
||||
<span className="flex items-center gap-1 text-gray-400">
|
||||
<Languages className="w-3 h-3 text-purple-400" />
|
||||
{team.languages.slice(0, 2).join(', ')}
|
||||
{team.languages.length > 2 && ` +${team.languages.length - 2}`}
|
||||
</span>
|
||||
)}
|
||||
{team.isRecruiting && (
|
||||
<span className="flex items-center gap-1 text-performance-green">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-performance-green animate-pulse" />
|
||||
Recruiting
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Members */}
|
||||
<div className="col-span-2 items-center justify-center hidden lg:flex">
|
||||
<span className="flex items-center gap-1 text-gray-400">
|
||||
<Users className="w-4 h-4" />
|
||||
{team.memberCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Rating */}
|
||||
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
|
||||
<span
|
||||
className={`font-mono font-semibold ${
|
||||
sortBy === 'rating' ? 'text-purple-400' : 'text-white'
|
||||
}`}
|
||||
>
|
||||
{getSafeRating(team).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Wins */}
|
||||
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
|
||||
<span className={`font-mono font-semibold ${sortBy === 'wins' ? 'text-purple-400' : 'text-white'}`}>
|
||||
{getSafeTotalWins(team)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Win Rate */}
|
||||
<div className="col-span-2 flex items-center justify-center">
|
||||
<span className={`font-mono font-semibold ${sortBy === 'winRate' ? 'text-purple-400' : 'text-white'}`}>
|
||||
{winRate}%
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredAndSortedTeams.length === 0 && (
|
||||
<div className="py-16 text-center">
|
||||
<Trophy className="w-12 h-12 text-gray-600 mx-auto mb-4" />
|
||||
<p className="text-gray-400 mb-2">No teams found</p>
|
||||
<p className="text-sm text-gray-500">Try adjusting your filters or search query</p>
|
||||
<Container size="lg" py={8}>
|
||||
<Stack gap={8}>
|
||||
{/* Header */}
|
||||
<Box>
|
||||
<Box mb={6}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
onSearchChange('');
|
||||
onFilterLevelChange('all');
|
||||
}}
|
||||
className="mt-4"
|
||||
onClick={onBackToTeams}
|
||||
icon={<Icon icon={ArrowLeft} size={4} />}
|
||||
>
|
||||
Clear Filters
|
||||
Back to Teams
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Surface variant="muted" rounded="xl" padding={3} style={{ background: 'linear-gradient(to bottom right, rgba(250, 204, 21, 0.2), rgba(217, 119, 6, 0.1))', border: '1px solid rgba(250, 204, 21, 0.3)' }}>
|
||||
<Icon icon={Award} size={7} color="#facc15" />
|
||||
</Surface>
|
||||
<Box>
|
||||
<Heading level={1}>Team Leaderboard</Heading>
|
||||
<Text color="text-gray-400" block mt={1}>Rankings of all teams by performance metrics</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<TeamRankingsFilter
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={onSearchChange}
|
||||
filterLevel={filterLevel}
|
||||
onFilterLevelChange={onFilterLevelChange}
|
||||
sortBy={sortBy}
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
|
||||
{/* Podium for Top 3 */}
|
||||
{sortBy === 'rating' && filterLevel === 'all' && !searchQuery && filteredAndSortedTeams.length >= 3 && (
|
||||
<TopThreePodium teams={filteredAndSortedTeams} onClick={onTeamClick} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leaderboard Table */}
|
||||
<TeamRankingsTable
|
||||
teams={filteredAndSortedTeams}
|
||||
sortBy={sortBy}
|
||||
onTeamClick={onTeamClick}
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,102 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { Trophy, Users } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { Users } from 'lucide-react';
|
||||
import { TeamLeaderboardPreview } from '@/components/teams/TeamLeaderboardPreview';
|
||||
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 '@/components/teams/TeamCard';
|
||||
import { EmptyState } from '@/components/shared/state/EmptyState';
|
||||
import type { TeamSummaryData, TeamsViewData } from '@/lib/view-data/TeamsViewData';
|
||||
|
||||
import TeamLeaderboardPreview from '@/components/teams/TeamLeaderboardPreview';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import type { TeamSummaryData, TeamsViewData } from '../lib/view-data/TeamsViewData';
|
||||
|
||||
interface TeamsTemplateProps extends TeamsViewData {
|
||||
searchQuery?: string;
|
||||
showCreateForm?: boolean;
|
||||
onSearchChange?: (query: string) => void;
|
||||
onShowCreateForm?: () => void;
|
||||
onHideCreateForm?: () => void;
|
||||
interface TeamsTemplateProps {
|
||||
viewData: TeamsViewData;
|
||||
onTeamClick?: (teamId: string) => void;
|
||||
onCreateSuccess?: (teamId: string) => void;
|
||||
onBrowseTeams?: () => void;
|
||||
onSkillLevelClick?: (level: string) => void;
|
||||
onViewFullLeaderboard: () => void;
|
||||
onCreateTeam: () => void;
|
||||
}
|
||||
|
||||
export function TeamsTemplate({ teams }: TeamsTemplateProps) {
|
||||
export function TeamsTemplate({ viewData, onTeamClick, onViewFullLeaderboard, onCreateTeam }: TeamsTemplateProps) {
|
||||
const { teams } = viewData;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-deep-graphite py-8">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Teams</h1>
|
||||
<p className="text-gray-400">Browse and manage your racing teams</p>
|
||||
</div>
|
||||
<Link href=routes.team.detail('create')>
|
||||
<Button variant="primary">Create Team</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Teams Grid */}
|
||||
{teams.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{teams.map((team: TeamSummaryData) => (
|
||||
<Card key={team.teamId} className="hover:border-primary-blue/50 transition-colors">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{team.logoUrl ? (
|
||||
<img
|
||||
src={team.logoUrl}
|
||||
alt={team.teamName}
|
||||
className="w-12 h-12 rounded-lg object-cover bg-iron-gray"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg bg-iron-gray flex items-center justify-center">
|
||||
<Users className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">{team.teamName}</h3>
|
||||
<p className="text-sm text-gray-400">{team.leagueName}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400 mb-4">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-4 h-4" />
|
||||
{team.memberCount} members
|
||||
</span>
|
||||
</div>
|
||||
{/* 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'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/teams/${team.teamId}`} className="flex-1">
|
||||
<Button variant="secondary" className="w-full text-sm">
|
||||
View Team
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
<Users className="w-16 h-16 text-gray-600 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-white mb-2">No teams yet</h3>
|
||||
<p className="text-gray-400 mb-4">Get started by creating your first racing team</p>
|
||||
<Link href=routes.team.detail('create')>
|
||||
<Button variant="primary">Create Team</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team Leaderboard Preview */}
|
||||
<div className="mt-12">
|
||||
<h2 className="text-2xl font-bold text-white mb-6 flex items-center gap-2">
|
||||
<Trophy className="w-6 h-6 text-yellow-400" />
|
||||
Top Teams
|
||||
</h2>
|
||||
<TeamLeaderboardPreview topTeams={[]} onTeamClick={() => {}} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{/* Team Leaderboard Preview */}
|
||||
<Box mt={12}>
|
||||
<TeamLeaderboardPreview
|
||||
topTeams={[]}
|
||||
onTeamClick={(id) => onTeamClick?.(id)}
|
||||
onViewFullLeaderboard={onViewFullLeaderboard}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
/**
|
||||
* Forgot Password Template
|
||||
*
|
||||
* Pure presentation component that accepts ViewData only.
|
||||
* No business logic, no state management.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import React from 'react';
|
||||
import {
|
||||
Mail,
|
||||
ArrowLeft,
|
||||
@@ -17,11 +9,17 @@ import {
|
||||
Shield,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import { Card } from '@/ui/Card';
|
||||
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 { ForgotPasswordViewData } from '@/lib/builders/view-data/types/ForgotPasswordViewData';
|
||||
|
||||
interface ForgotPasswordTemplateProps {
|
||||
@@ -39,156 +37,145 @@ interface ForgotPasswordTemplateProps {
|
||||
|
||||
export function ForgotPasswordTemplate({ viewData, formActions, mutationState }: ForgotPasswordTemplateProps) {
|
||||
return (
|
||||
<main className="min-h-screen bg-deep-graphite flex items-center justify-center px-4 py-12">
|
||||
<Box as="main" style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/5 via-transparent to-purple-600/5" />
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="relative w-full max-w-md">
|
||||
<Box style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom right, rgba(59, 130, 246, 0.05), transparent, rgba(147, 51, 234, 0.05))' }} />
|
||||
|
||||
<Box style={{ position: 'relative', width: '100%', maxWidth: '28rem', padding: '0 1rem' }}>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30 mx-auto mb-4">
|
||||
<Flag className="w-8 h-8 text-primary-blue" />
|
||||
</div>
|
||||
<Heading level={1} className="mb-2">Reset Password</Heading>
|
||||
<p className="text-gray-400">
|
||||
<Box style={{ textAlign: 'center' }} mb={8}>
|
||||
<Surface variant="muted" rounded="2xl" border padding={4} style={{ width: '4rem', height: '4rem', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 1rem' }}>
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Card className="relative overflow-hidden">
|
||||
<Card style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
{/* Background accent */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-bl from-primary-blue/10 to-transparent rounded-bl-full" />
|
||||
<Box style={{ position: 'absolute', top: 0, right: 0, width: '8rem', height: '8rem', background: 'linear-gradient(to bottom left, rgba(59, 130, 246, 0.1), transparent)', borderBottomLeftRadius: '9999px' }} />
|
||||
|
||||
{!viewData.showSuccess ? (
|
||||
<form onSubmit={formActions.handleSubmit} className="relative space-y-5">
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={viewData.formState.fields.email.value}
|
||||
onChange={formActions.handleChange}
|
||||
error={!!viewData.formState.fields.email.error}
|
||||
errorMessage={viewData.formState.fields.email.error}
|
||||
placeholder="you@example.com"
|
||||
disabled={mutationState.isPending}
|
||||
className="pl-10"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={formActions.handleSubmit}>
|
||||
<Stack gap={5} style={{ position: 'relative' }}>
|
||||
{/* Email */}
|
||||
<Box>
|
||||
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
|
||||
Email Address
|
||||
</Text>
|
||||
<Box position="relative">
|
||||
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-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}
|
||||
style={{ paddingLeft: '2.5rem' }}
|
||||
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 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-start gap-3 p-3 rounded-lg bg-red-500/10 border border-red-500/30"
|
||||
>
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-400">{mutationState.error}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={mutationState.isPending}
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{mutationState.isPending ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="w-4 h-4" />
|
||||
Send Reset Link
|
||||
</>
|
||||
{/* Error Message */}
|
||||
{mutationState.error && (
|
||||
<Surface variant="muted" rounded="lg" border padding={3} style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', borderColor: 'rgba(239, 68, 68, 0.3)' }}>
|
||||
<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>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Back to Login */}
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/auth/login"
|
||||
className="text-sm text-primary-blue hover:underline flex items-center justify-center gap-1"
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={mutationState.isPending}
|
||||
fullWidth
|
||||
icon={mutationState.isPending ? <LoadingSpinner size={4} color="white" /> : <Icon icon={Shield} size={4} />}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
{mutationState.isPending ? 'Sending...' : 'Send Reset Link'}
|
||||
</Button>
|
||||
|
||||
{/* Back to Login */}
|
||||
<Box style={{ textAlign: 'center' }}>
|
||||
<Link href="/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>
|
||||
</Stack>
|
||||
</form>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="relative space-y-4"
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-performance-green/10 border border-performance-green/30">
|
||||
<CheckCircle2 className="w-6 h-6 text-performance-green flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-performance-green font-medium">{viewData.successMessage}</p>
|
||||
{viewData.magicLink && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-gray-400 mb-1">Development Mode - Magic Link:</p>
|
||||
<div className="bg-iron-gray p-2 rounded border border-charcoal-outline">
|
||||
<code className="text-xs text-primary-blue break-all">
|
||||
{viewData.magicLink}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500 mt-1">
|
||||
In production, this would be sent via email
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Stack gap={4} style={{ position: 'relative' }}>
|
||||
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)', borderColor: 'rgba(16, 185, 129, 0.3)' }}>
|
||||
<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} style={{ backgroundColor: '#262626' }}>
|
||||
<Text size="xs" color="text-primary-blue" style={{ wordBreak: 'break-all' }}>{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'}
|
||||
className="w-full"
|
||||
fullWidth
|
||||
>
|
||||
Return to Login
|
||||
</Button>
|
||||
</motion.div>
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Trust Indicators */}
|
||||
<div className="mt-6 flex items-center justify-center gap-6 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>Secure reset process</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span>15 minute expiration</span>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="mt-6 text-center text-xs text-gray-500">
|
||||
Need help?{' '}
|
||||
<Link href="/support" className="text-gray-400 hover:underline">
|
||||
Contact support
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<Box mt={6} style={{ textAlign: 'center' }}>
|
||||
<Text size="xs" color="text-gray-500">
|
||||
Need help?{' '}
|
||||
<Link href="/support">
|
||||
<Text color="text-gray-400">Contact support</Text>
|
||||
</Link>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
/**
|
||||
* Login Template
|
||||
*
|
||||
* Pure presentation component that accepts ViewData only.
|
||||
* No business logic, no state management.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import React from 'react';
|
||||
import {
|
||||
Mail,
|
||||
Lock,
|
||||
@@ -19,11 +11,17 @@ import {
|
||||
Flag,
|
||||
Shield,
|
||||
} from 'lucide-react';
|
||||
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import { Card } from '@/ui/Card';
|
||||
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 { EnhancedFormError } from '@/components/errors/EnhancedFormError';
|
||||
import UserRolesPreview from '@/components/auth/UserRolesPreview';
|
||||
import AuthWorkflowMockup from '@/components/auth/AuthWorkflowMockup';
|
||||
@@ -47,141 +45,151 @@ interface LoginTemplateProps {
|
||||
|
||||
export function LoginTemplate({ viewData, formActions, mutationState }: LoginTemplateProps) {
|
||||
return (
|
||||
<main className="min-h-screen bg-deep-graphite flex">
|
||||
<Box as="main" style={{ minHeight: '100vh', display: 'flex', position: 'relative' }}>
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/5 via-transparent to-purple-600/5" />
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<Box style={{ position: 'absolute', inset: 0, background: '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) */}
|
||||
<div className="hidden lg:flex lg:w-1/2 relative items-center justify-center p-12">
|
||||
<div className="max-w-lg">
|
||||
<Box className="hidden lg:flex lg:w-1/2" style={{ position: 'relative', alignItems: 'center', justifyContent: 'center', padding: '3rem' }}>
|
||||
<Box style={{ maxWidth: '32rem' }}>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30">
|
||||
<Flag className="w-6 h-6 text-primary-blue" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-white">GridPilot</span>
|
||||
</div>
|
||||
<Stack direction="row" align="center" gap={3} mb={8}>
|
||||
<Surface variant="muted" rounded="xl" border padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', borderColor: 'rgba(59, 130, 246, 0.3)' }}>
|
||||
<Icon icon={Flag} size={6} color="#3b82f6" />
|
||||
</Surface>
|
||||
<Text size="2xl" weight="bold" color="text-white">GridPilot</Text>
|
||||
</Stack>
|
||||
|
||||
<Heading level={2} className="text-white mb-4">
|
||||
Your Sim Racing Infrastructure
|
||||
</Heading>
|
||||
<Box mb={4}>
|
||||
<Heading level={2}>
|
||||
Your Sim Racing Infrastructure
|
||||
</Heading>
|
||||
</Box>
|
||||
|
||||
<p className="text-gray-400 text-lg mb-8">
|
||||
<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.
|
||||
</p>
|
||||
</Text>
|
||||
|
||||
{/* Role Cards */}
|
||||
<UserRolesPreview variant="full" />
|
||||
|
||||
{/* Workflow Mockup */}
|
||||
<AuthWorkflowMockup />
|
||||
<Box mt={8}>
|
||||
<AuthWorkflowMockup />
|
||||
</Box>
|
||||
|
||||
{/* Trust Indicators */}
|
||||
<div className="mt-8 flex items-center gap-6 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>Secure login</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">iRacing verified</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 */}
|
||||
<div className="flex-1 flex items-center justify-center px-4 py-12">
|
||||
<div className="relative w-full max-w-md">
|
||||
<Box style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3rem 1rem', position: 'relative' }}>
|
||||
<Box style={{ width: '100%', maxWidth: '28rem' }}>
|
||||
{/* Mobile Logo/Header */}
|
||||
<div className="text-center mb-8 lg:hidden">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30 mx-auto mb-4">
|
||||
<Flag className="w-8 h-8 text-primary-blue" />
|
||||
</div>
|
||||
<Heading level={1} className="mb-2">Welcome Back</Heading>
|
||||
<p className="text-gray-400">
|
||||
<Box className="lg:hidden" style={{ textAlign: 'center' }} mb={8}>
|
||||
<Surface variant="muted" rounded="2xl" border padding={4} style={{ width: '4rem', height: '4rem', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 1rem' }}>
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Desktop Header */}
|
||||
<div className="hidden lg:block text-center mb-8">
|
||||
<Heading level={2} className="mb-2">Welcome Back</Heading>
|
||||
<p className="text-gray-400">
|
||||
<Box className="hidden lg:block" style={{ 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
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Card className="relative overflow-hidden">
|
||||
<Card style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
{/* Background accent */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-bl from-primary-blue/10 to-transparent rounded-bl-full" />
|
||||
<Box style={{ position: 'absolute', top: 0, right: 0, width: '8rem', height: '8rem', background: 'linear-gradient(to bottom left, rgba(59, 130, 246, 0.1), transparent)', borderBottomLeftRadius: '9999px' }} />
|
||||
|
||||
<form onSubmit={formActions.handleSubmit} className="relative space-y-5">
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={viewData.formState.fields.email.value as string}
|
||||
onChange={formActions.handleChange}
|
||||
error={!!viewData.formState.fields.email.error}
|
||||
errorMessage={viewData.formState.fields.email.error}
|
||||
placeholder="you@example.com"
|
||||
disabled={viewData.formState.isSubmitting || mutationState.isPending}
|
||||
className="pl-10"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={formActions.handleSubmit}>
|
||||
<Stack gap={5} style={{ position: 'relative' }}>
|
||||
{/* Email */}
|
||||
<Box>
|
||||
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
|
||||
Email Address
|
||||
</Text>
|
||||
<Box position="relative">
|
||||
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-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}
|
||||
style={{ paddingLeft: '2.5rem' }}
|
||||
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 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-300">
|
||||
Password
|
||||
</label>
|
||||
<Link href="/auth/forgot-password" className="text-xs text-primary-blue hover:underline">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type={viewData.showPassword ? 'text' : 'password'}
|
||||
value={viewData.formState.fields.password.value as string}
|
||||
onChange={formActions.handleChange}
|
||||
error={!!viewData.formState.fields.password.error}
|
||||
errorMessage={viewData.formState.fields.password.error}
|
||||
placeholder="••••••••"
|
||||
disabled={viewData.formState.isSubmitting || mutationState.isPending}
|
||||
className="pl-10 pr-10"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => formActions.setShowPassword(!viewData.showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
{viewData.showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Password */}
|
||||
<Box>
|
||||
<Stack direction="row" align="center" justify="between" mb={2}>
|
||||
<Text size="sm" weight="medium" color="text-gray-300">
|
||||
Password
|
||||
</Text>
|
||||
<Link href="/auth/forgot-password">
|
||||
<Text size="xs" color="text-primary-blue">Forgot password?</Text>
|
||||
</Link>
|
||||
</Stack>
|
||||
<Box position="relative">
|
||||
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-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}
|
||||
style={{ paddingLeft: '2.5rem', paddingRight: '2.5rem' }}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<Box
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => formActions.setShowPassword(!viewData.showPassword)}
|
||||
style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: '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 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
{/* Remember Me */}
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<input
|
||||
id="rememberMe"
|
||||
name="rememberMe"
|
||||
@@ -191,34 +199,25 @@ export function LoginTemplate({ viewData, formActions, mutationState }: LoginTem
|
||||
disabled={viewData.formState.isSubmitting || mutationState.isPending}
|
||||
className="w-4 h-4 rounded border-charcoal-outline bg-iron-gray text-primary-blue focus:ring-primary-blue focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">Keep me signed in</span>
|
||||
</label>
|
||||
</div>
|
||||
<Text size="sm" color="text-gray-300">Keep me signed in</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Insufficient Permissions Message */}
|
||||
<AnimatePresence>
|
||||
{/* Insufficient Permissions Message */}
|
||||
{viewData.hasInsufficientPermissions && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-warning-amber flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-gray-300">
|
||||
<strong className="text-warning-amber">Insufficient Permissions</strong>
|
||||
<p className="mt-1">
|
||||
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)', borderColor: 'rgba(245, 158, 11, 0.3)' }}>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Surface>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Enhanced Error Display */}
|
||||
<AnimatePresence>
|
||||
{/* Enhanced Error Display */}
|
||||
{viewData.submitError && (
|
||||
<EnhancedFormError
|
||||
error={new Error(viewData.submitError)}
|
||||
@@ -228,73 +227,77 @@ export function LoginTemplate({ viewData, formActions, mutationState }: LoginTem
|
||||
showDeveloperDetails={viewData.showErrorDetails}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={viewData.formState.isSubmitting || mutationState.isPending}
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{mutationState.isPending || viewData.formState.isSubmitting ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="w-4 h-4" />
|
||||
Sign In
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{/* 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>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-charcoal-outline" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs">
|
||||
<span className="px-4 bg-iron-gray text-gray-500">or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
<Box style={{ position: 'relative' }} my={6}>
|
||||
<Box style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center' }}>
|
||||
<Box style={{ width: '100%', borderTop: '1px solid #262626' }} />
|
||||
</Box>
|
||||
<Box style={{ position: 'relative', display: 'flex', justifyContent: 'center' }}>
|
||||
<Box px={4} style={{ backgroundColor: '#171717' }}>
|
||||
<Text size="xs" color="text-gray-500">or continue with</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Sign Up Link */}
|
||||
<p className="mt-6 text-center text-sm text-gray-400">
|
||||
Don't have an account?{''}
|
||||
<Link
|
||||
href={viewData.returnTo && viewData.returnTo !== '/dashboard' ? `/auth/signup?returnTo=${encodeURIComponent(viewData.returnTo)}` : '/auth/signup'}
|
||||
className="text-primary-blue hover:underline font-medium"
|
||||
>
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
<Box style={{ 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 */}
|
||||
<div className="mt-6 p-4 rounded-lg bg-iron-gray/30 border border-charcoal-outline">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-gray-400">
|
||||
<strong>Note:</strong> Your display name cannot be changed after signup. Please ensure it's correct when creating your account.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Box mt={6}>
|
||||
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(38, 38, 38, 0.3)', borderColor: '#262626' }}>
|
||||
<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.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Surface>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="mt-6 text-center text-xs text-gray-500">
|
||||
By signing in, you agree to our{''}
|
||||
<Link href="/terms" className="text-gray-400 hover:underline">Terms of Service</Link>
|
||||
{''}and{''}
|
||||
<Link href="/privacy" className="text-gray-400 hover:underline">Privacy Policy</Link>
|
||||
</p>
|
||||
<Box mt={6} style={{ 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>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Mobile Role Info */}
|
||||
<UserRolesPreview variant="compact" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Box mt={8} className="lg:hidden">
|
||||
<UserRolesPreview variant="compact" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
/**
|
||||
* Reset Password Template
|
||||
*
|
||||
* Pure presentation component that accepts ViewData only.
|
||||
* No business logic, no state management.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import React from 'react';
|
||||
import {
|
||||
Lock,
|
||||
Eye,
|
||||
@@ -19,11 +11,17 @@ import {
|
||||
CheckCircle2,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import { Card } from '@/ui/Card';
|
||||
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 { ResetPasswordViewData } from '@/lib/builders/view-data/types/ResetPasswordViewData';
|
||||
|
||||
interface ResetPasswordTemplateProps extends ResetPasswordViewData {
|
||||
@@ -48,184 +46,183 @@ export function ResetPasswordTemplate(props: ResetPasswordTemplateProps) {
|
||||
const { formActions, uiState, mutationState, ...viewData } = props;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-deep-graphite flex items-center justify-center px-4 py-12">
|
||||
<Box as="main" style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/5 via-transparent to-purple-600/5" />
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="relative w-full max-w-md">
|
||||
<Box style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom right, rgba(59, 130, 246, 0.05), transparent, rgba(147, 51, 234, 0.05))' }} />
|
||||
|
||||
<Box style={{ position: 'relative', width: '100%', maxWidth: '28rem', padding: '0 1rem' }}>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30 mx-auto mb-4">
|
||||
<Flag className="w-8 h-8 text-primary-blue" />
|
||||
</div>
|
||||
<Heading level={1} className="mb-2">Reset Password</Heading>
|
||||
<p className="text-gray-400">
|
||||
<Box style={{ textAlign: 'center' }} mb={8}>
|
||||
<Surface variant="muted" rounded="2xl" border padding={4} style={{ width: '4rem', height: '4rem', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 1rem' }}>
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Card className="relative overflow-hidden">
|
||||
<Card style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
{/* Background accent */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-bl from-primary-blue/10 to-transparent rounded-bl-full" />
|
||||
<Box style={{ position: 'absolute', top: 0, right: 0, width: '8rem', height: '8rem', background: 'linear-gradient(to bottom left, rgba(59, 130, 246, 0.1), transparent)', borderBottomLeftRadius: '9999px' }} />
|
||||
|
||||
{!viewData.showSuccess ? (
|
||||
<form onSubmit={formActions.handleSubmit} className="relative space-y-5">
|
||||
{/* New Password */}
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
New Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<Input
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
type={uiState.showPassword ? 'text' : 'password'}
|
||||
value={viewData.formState.fields.newPassword.value}
|
||||
onChange={formActions.handleChange}
|
||||
error={!!viewData.formState.fields.newPassword.error}
|
||||
errorMessage={viewData.formState.fields.newPassword.error}
|
||||
placeholder="••••••••"
|
||||
disabled={mutationState.isPending}
|
||||
className="pl-10 pr-10"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => formActions.setShowPassword(!uiState.showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
{uiState.showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={formActions.handleSubmit}>
|
||||
<Stack gap={5} style={{ position: 'relative' }}>
|
||||
{/* New Password */}
|
||||
<Box>
|
||||
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
|
||||
New Password
|
||||
</Text>
|
||||
<Box position="relative">
|
||||
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-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}
|
||||
style={{ paddingLeft: '2.5rem', paddingRight: '2.5rem' }}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Box
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => formActions.setShowPassword(!uiState.showPassword)}
|
||||
style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: '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 */}
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type={uiState.showConfirmPassword ? 'text' : 'password'}
|
||||
value={viewData.formState.fields.confirmPassword.value}
|
||||
onChange={formActions.handleChange}
|
||||
error={!!viewData.formState.fields.confirmPassword.error}
|
||||
errorMessage={viewData.formState.fields.confirmPassword.error}
|
||||
placeholder="••••••••"
|
||||
disabled={mutationState.isPending}
|
||||
className="pl-10 pr-10"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => formActions.setShowConfirmPassword(!uiState.showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
{uiState.showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Confirm Password */}
|
||||
<Box>
|
||||
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
|
||||
Confirm Password
|
||||
</Text>
|
||||
<Box position="relative">
|
||||
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-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}
|
||||
style={{ paddingLeft: '2.5rem', paddingRight: '2.5rem' }}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Box
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => formActions.setShowConfirmPassword(!uiState.showConfirmPassword)}
|
||||
style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: '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 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-start gap-3 p-3 rounded-lg bg-red-500/10 border border-red-500/30"
|
||||
>
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-400">{mutationState.error}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={mutationState.isPending}
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{mutationState.isPending ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Resetting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="w-4 h-4" />
|
||||
Reset Password
|
||||
</>
|
||||
{/* Error Message */}
|
||||
{mutationState.error && (
|
||||
<Surface variant="muted" rounded="lg" border padding={3} style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', borderColor: 'rgba(239, 68, 68, 0.3)' }}>
|
||||
<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>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Back to Login */}
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/auth/login"
|
||||
className="text-sm text-primary-blue hover:underline flex items-center justify-center gap-1"
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={mutationState.isPending}
|
||||
fullWidth
|
||||
icon={mutationState.isPending ? <LoadingSpinner size={4} color="white" /> : <Icon icon={Shield} size={4} />}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
{mutationState.isPending ? 'Resetting...' : 'Reset Password'}
|
||||
</Button>
|
||||
|
||||
{/* Back to Login */}
|
||||
<Box style={{ textAlign: 'center' }}>
|
||||
<Link href="/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>
|
||||
</Stack>
|
||||
</form>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="relative space-y-4"
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-performance-green/10 border border-performance-green/30">
|
||||
<CheckCircle2 className="w-6 h-6 text-performance-green flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-performance-green font-medium">{viewData.successMessage}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Your password has been successfully reset
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Stack gap={4} style={{ position: 'relative' }}>
|
||||
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)', borderColor: 'rgba(16, 185, 129, 0.3)' }}>
|
||||
<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'}
|
||||
className="w-full"
|
||||
fullWidth
|
||||
>
|
||||
Return to Login
|
||||
</Button>
|
||||
</motion.div>
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Trust Indicators */}
|
||||
<div className="mt-6 flex items-center justify-center gap-6 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>Secure password reset</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span>Encrypted transmission</span>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="mt-6 text-center text-xs text-gray-500">
|
||||
Need help?{' '}
|
||||
<Link href="/support" className="text-gray-400 hover:underline">
|
||||
Contact support
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<Box mt={6} style={{ textAlign: 'center' }}>
|
||||
<Text size="xs" color="text-gray-500">
|
||||
Need help?{' '}
|
||||
<Link href="/support">
|
||||
<Text color="text-gray-400">Contact support</Text>
|
||||
</Link>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
/**
|
||||
* Signup Template
|
||||
*
|
||||
* Pure presentation component that accepts ViewData only.
|
||||
* No business logic, no state management.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import React from 'react';
|
||||
import {
|
||||
Mail,
|
||||
Lock,
|
||||
@@ -26,11 +18,17 @@ import {
|
||||
Shield,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import { Card } from '@/ui/Card';
|
||||
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 { SignupViewData } from '@/lib/builders/view-data/types/SignupViewData';
|
||||
import { checkPasswordStrength } from '@/lib/utils/validation';
|
||||
|
||||
@@ -57,19 +55,19 @@ const USER_ROLES = [
|
||||
icon: Car,
|
||||
title: 'Driver',
|
||||
description: 'Race, track stats, join teams',
|
||||
color: 'primary-blue',
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
icon: Trophy,
|
||||
title: 'League Admin',
|
||||
description: 'Organize leagues and events',
|
||||
color: 'performance-green',
|
||||
color: '#10b981',
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Team Manager',
|
||||
description: 'Manage team and drivers',
|
||||
color: 'purple-400',
|
||||
color: '#a855f7',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -93,362 +91,380 @@ export function SignupTemplate({ viewData, formActions, uiState, mutationState }
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-deep-graphite flex">
|
||||
<Box as="main" style={{ minHeight: '100vh', display: 'flex', position: 'relative' }}>
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/5 via-transparent to-purple-600/5" />
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<Box style={{ position: 'absolute', inset: 0, background: '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) */}
|
||||
<div className="hidden lg:flex lg:w-1/2 relative items-center justify-center p-12">
|
||||
<div className="max-w-lg">
|
||||
<Box className="hidden lg:flex lg:w-1/2" style={{ position: 'relative', alignItems: 'center', justifyContent: 'center', padding: '3rem' }}>
|
||||
<Box style={{ maxWidth: '32rem' }}>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30">
|
||||
<Flag className="w-6 h-6 text-primary-blue" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-white">GridPilot</span>
|
||||
</div>
|
||||
<Stack direction="row" align="center" gap={3} mb={8}>
|
||||
<Surface variant="muted" rounded="xl" border padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', borderColor: 'rgba(59, 130, 246, 0.3)' }}>
|
||||
<Icon icon={Flag} size={6} color="#3b82f6" />
|
||||
</Surface>
|
||||
<Text size="2xl" weight="bold" color="text-white">GridPilot</Text>
|
||||
</Stack>
|
||||
|
||||
<Heading level={2} className="text-white mb-4">
|
||||
Start Your Racing Journey
|
||||
</Heading>
|
||||
<Box mb={4}>
|
||||
<Heading level={2}>Start Your Racing Journey</Heading>
|
||||
</Box>
|
||||
|
||||
<p className="text-gray-400 text-lg mb-8">
|
||||
<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.
|
||||
</p>
|
||||
</Text>
|
||||
|
||||
{/* Role Cards */}
|
||||
<div className="space-y-3 mb-8">
|
||||
{USER_ROLES.map((role, index) => (
|
||||
<motion.div
|
||||
<Stack gap={3} mb={8}>
|
||||
{USER_ROLES.map((role) => (
|
||||
<Surface
|
||||
key={role.title}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="flex items-center gap-4 p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline"
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
border
|
||||
padding={4}
|
||||
style={{ backgroundColor: 'rgba(38, 38, 38, 0.3)', borderColor: '#262626' }}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg bg-${role.color}/20 flex items-center justify-center`}>
|
||||
<role.icon className={`w-5 h-5 text-${role.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-white font-medium">{role.title}</h4>
|
||||
<p className="text-sm text-gray-500">{role.description}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: `${role.color}1A` }}>
|
||||
<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>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Surface>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{/* Features List */}
|
||||
<div className="bg-iron-gray/30 rounded-xl border border-charcoal-outline p-5 mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Sparkles className="w-4 h-4 text-primary-blue" />
|
||||
<span className="text-sm font-medium text-white">What you'll get</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{FEATURES.map((feature, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="flex items-center gap-2 text-sm text-gray-400"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5 text-performance-green flex-shrink-0" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Box mb={8}>
|
||||
<Surface variant="muted" rounded="xl" border padding={5} style={{ backgroundColor: 'rgba(38, 38, 38, 0.2)', borderColor: '#262626' }}>
|
||||
<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 */}
|
||||
<div className="flex items-center gap-6 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>Secure signup</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>iRacing integration</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 */}
|
||||
<div className="flex-1 flex items-center justify-center px-4 py-12 overflow-y-auto">
|
||||
<div className="relative w-full max-w-md">
|
||||
<Box style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3rem 1rem', position: 'relative', overflowY: 'auto' }}>
|
||||
<Box style={{ width: '100%', maxWidth: '28rem' }}>
|
||||
{/* Mobile Logo/Header */}
|
||||
<div className="text-center mb-8 lg:hidden">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30 mx-auto mb-4">
|
||||
<Flag className="w-8 h-8 text-primary-blue" />
|
||||
</div>
|
||||
<Heading level={1} className="mb-2">Join GridPilot</Heading>
|
||||
<p className="text-gray-400">
|
||||
<Box className="lg:hidden" style={{ textAlign: 'center' }} mb={8}>
|
||||
<Surface variant="muted" rounded="2xl" border padding={4} style={{ width: '4rem', height: '4rem', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 1rem' }}>
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Desktop Header */}
|
||||
<div className="hidden lg:block text-center mb-8">
|
||||
<Heading level={2} className="mb-2">Create Account</Heading>
|
||||
<p className="text-gray-400">
|
||||
<Box className="hidden lg:block" style={{ textAlign: 'center' }} mb={8}>
|
||||
<Heading level={2}>Create Account</Heading>
|
||||
<Text color="text-gray-400" block mt={2}>
|
||||
Get started with your free account
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Card className="relative overflow-hidden">
|
||||
<Card style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
{/* Background accent */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-bl from-primary-blue/10 to-transparent rounded-bl-full" />
|
||||
<Box style={{ position: 'absolute', top: 0, right: 0, width: '8rem', height: '8rem', background: 'linear-gradient(to bottom left, rgba(59, 130, 246, 0.1), transparent)', borderBottomLeftRadius: '9999px' }} />
|
||||
|
||||
<form onSubmit={formActions.handleSubmit} className="relative space-y-4">
|
||||
{/* First Name */}
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
First Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<Input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
value={viewData.formState.fields.firstName.value}
|
||||
onChange={formActions.handleChange}
|
||||
error={!!viewData.formState.fields.firstName.error}
|
||||
errorMessage={viewData.formState.fields.firstName.error}
|
||||
placeholder="John"
|
||||
disabled={mutationState.isPending}
|
||||
className="pl-10"
|
||||
autoComplete="given-name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={formActions.handleSubmit}>
|
||||
<Stack gap={4} style={{ position: 'relative' }}>
|
||||
{/* First Name */}
|
||||
<Box>
|
||||
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
|
||||
First Name
|
||||
</Text>
|
||||
<Box position="relative">
|
||||
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-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}
|
||||
style={{ paddingLeft: '2.5rem' }}
|
||||
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 */}
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Last Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<Input
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
type="text"
|
||||
value={viewData.formState.fields.lastName.value}
|
||||
onChange={formActions.handleChange}
|
||||
error={!!viewData.formState.fields.lastName.error}
|
||||
errorMessage={viewData.formState.fields.lastName.error}
|
||||
placeholder="Smith"
|
||||
disabled={mutationState.isPending}
|
||||
className="pl-10"
|
||||
autoComplete="family-name"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">Your name will be used as-is and cannot be changed later</p>
|
||||
</div>
|
||||
{/* Last Name */}
|
||||
<Box>
|
||||
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
|
||||
Last Name
|
||||
</Text>
|
||||
<Box position="relative">
|
||||
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-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}
|
||||
style={{ paddingLeft: '2.5rem' }}
|
||||
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 */}
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
|
||||
<AlertCircle className="w-5 h-5 text-warning-amber flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-warning-amber">
|
||||
<strong>Important:</strong> Your name cannot be changed after signup. Please ensure it's correct.
|
||||
</div>
|
||||
</div>
|
||||
{/* Name Immutability Warning */}
|
||||
<Surface variant="muted" rounded="lg" border padding={3} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)', borderColor: 'rgba(245, 158, 11, 0.3)' }}>
|
||||
<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 */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={viewData.formState.fields.email.value}
|
||||
onChange={formActions.handleChange}
|
||||
error={!!viewData.formState.fields.email.error}
|
||||
errorMessage={viewData.formState.fields.email.error}
|
||||
placeholder="you@example.com"
|
||||
disabled={mutationState.isPending}
|
||||
className="pl-10"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Email */}
|
||||
<Box>
|
||||
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
|
||||
Email Address
|
||||
</Text>
|
||||
<Box position="relative">
|
||||
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-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}
|
||||
style={{ paddingLeft: '2.5rem' }}
|
||||
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 */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type={uiState.showPassword ? 'text' : 'password'}
|
||||
value={viewData.formState.fields.password.value}
|
||||
onChange={formActions.handleChange}
|
||||
error={!!viewData.formState.fields.password.error}
|
||||
errorMessage={viewData.formState.fields.password.error}
|
||||
placeholder="••••••••"
|
||||
disabled={mutationState.isPending}
|
||||
className="pl-10 pr-10"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => formActions.setShowPassword(!uiState.showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
{uiState.showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{/* Password */}
|
||||
<Box>
|
||||
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
|
||||
Password
|
||||
</Text>
|
||||
<Box position="relative">
|
||||
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-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}
|
||||
style={{ paddingLeft: '2.5rem', paddingRight: '2.5rem' }}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Box
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => formActions.setShowPassword(!uiState.showPassword)}
|
||||
style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: '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 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-charcoal-outline overflow-hidden">
|
||||
<motion.div
|
||||
className={`h-full ${passwordStrength.color}`}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(passwordStrength.score / 5) * 100}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-xs font-medium ${
|
||||
passwordStrength.score <= 1 ? 'text-red-400' :
|
||||
passwordStrength.score <= 2 ? 'text-warning-amber' :
|
||||
passwordStrength.score <= 3 ? 'text-primary-blue' :
|
||||
'text-performance-green'
|
||||
}`}>
|
||||
{passwordStrength.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{passwordRequirements.map((req, index) => (
|
||||
<div key={index} className="flex items-center gap-1.5 text-xs">
|
||||
{req.met ? (
|
||||
<Check className="w-3 h-3 text-performance-green" />
|
||||
) : (
|
||||
<X className="w-3 h-3 text-gray-500" />
|
||||
)}
|
||||
<span className={req.met ? 'text-gray-300' : 'text-gray-500'}>
|
||||
{req.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Password Strength */}
|
||||
{viewData.formState.fields.password.value && (
|
||||
<Box mt={3}>
|
||||
<Stack direction="row" align="center" gap={2} mb={2}>
|
||||
<Box style={{ flex: 1, height: '0.375rem', borderRadius: '9999px', backgroundColor: '#262626', overflow: 'hidden' }}>
|
||||
<Box style={{ height: '100%', width: `${(passwordStrength.score / 5) * 100}%`, backgroundColor: passwordStrength.color === 'bg-red-500' ? '#ef4444' : passwordStrength.color === 'bg-yellow-500' ? '#f59e0b' : passwordStrength.color === 'bg-blue-500' ? '#3b82f6' : '#10b981' }} />
|
||||
</Box>
|
||||
<Text size="xs" weight="medium" style={{ color: passwordStrength.color === 'bg-red-500' ? '#f87171' : passwordStrength.color === 'bg-yellow-500' ? '#fbbf24' : passwordStrength.color === 'bg-blue-500' ? '#60a5fa' : '#34d399' }}>
|
||||
{passwordStrength.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '0.25rem' }}>
|
||||
{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 */}
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type={uiState.showConfirmPassword ? 'text' : 'password'}
|
||||
value={viewData.formState.fields.confirmPassword.value}
|
||||
onChange={formActions.handleChange}
|
||||
error={!!viewData.formState.fields.confirmPassword.error}
|
||||
errorMessage={viewData.formState.fields.confirmPassword.error}
|
||||
placeholder="••••••••"
|
||||
disabled={mutationState.isPending}
|
||||
className="pl-10 pr-10"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => formActions.setShowConfirmPassword(!uiState.showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
{uiState.showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{viewData.formState.fields.confirmPassword.value && viewData.formState.fields.password.value === viewData.formState.fields.confirmPassword.value && (
|
||||
<p className="mt-1 text-xs text-performance-green flex items-center gap-1">
|
||||
<Check className="w-3 h-3" /> Passwords match
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Confirm Password */}
|
||||
<Box>
|
||||
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
|
||||
Confirm Password
|
||||
</Text>
|
||||
<Box position="relative">
|
||||
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-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}
|
||||
style={{ paddingLeft: '2.5rem', paddingRight: '2.5rem' }}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Box
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => formActions.setShowConfirmPassword(!uiState.showConfirmPassword)}
|
||||
style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: '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>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={mutationState.isPending}
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{mutationState.isPending ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Creating account...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
Create Account
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{/* 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>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-charcoal-outline" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs">
|
||||
<span className="px-4 bg-iron-gray text-gray-500">or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
<Box style={{ position: 'relative' }} my={6}>
|
||||
<Box style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center' }}>
|
||||
<Box style={{ width: '100%', borderTop: '1px solid #262626' }} />
|
||||
</Box>
|
||||
<Box style={{ position: 'relative', display: 'flex', justifyContent: 'center' }}>
|
||||
<Box px={4} style={{ backgroundColor: '#171717' }}>
|
||||
<Text size="xs" color="text-gray-500">or continue with</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Login Link */}
|
||||
<p className="mt-6 text-center text-sm text-gray-400">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
href={viewData.returnTo && viewData.returnTo !== '/onboarding' ? `/auth/login?returnTo=${encodeURIComponent(viewData.returnTo)}` : '/auth/login'}
|
||||
className="text-primary-blue hover:underline font-medium"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
<Box style={{ 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 */}
|
||||
<p className="mt-6 text-center text-xs text-gray-500">
|
||||
By creating an account, you agree to our{' '}
|
||||
<Link href="/terms" className="text-gray-400 hover:underline">Terms of Service</Link>
|
||||
{' '}and{' '}
|
||||
<Link href="/privacy" className="text-gray-400 hover:underline">Privacy Policy</Link>
|
||||
</p>
|
||||
<Box mt={6} style={{ 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 */}
|
||||
<div className="mt-8 lg:hidden">
|
||||
<p className="text-center text-xs text-gray-500 mb-4">One account for all roles</p>
|
||||
<div className="flex justify-center gap-6">
|
||||
<Box mt={8} className="lg:hidden">
|
||||
<Text size="xs" color="text-gray-500" block mb={4} style={{ textAlign: 'center' }}>One account for all roles</Text>
|
||||
<Stack direction="row" align="center" justify="center" gap={6}>
|
||||
{USER_ROLES.map((role) => (
|
||||
<div key={role.title} className="flex flex-col items-center">
|
||||
<div className={`w-8 h-8 rounded-lg bg-${role.color}/20 flex items-center justify-center mb-1`}>
|
||||
<role.icon className={`w-4 h-4 text-${role.color}`} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{role.title}</span>
|
||||
</div>
|
||||
<Stack key={role.title} align="center" gap={1}>
|
||||
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: `${role.color}1A` }}>
|
||||
<Icon icon={role.icon} size={4} color={role.color} />
|
||||
</Surface>
|
||||
<Text size="xs" color="text-gray-500">{role.title}</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
16
apps/website/ui/AuthContainer.tsx
Normal file
16
apps/website/ui/AuthContainer.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
|
||||
interface AuthContainerProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthContainer({ children }: AuthContainerProps) {
|
||||
return (
|
||||
<Box style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem', backgroundColor: '#0f1115' }}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
18
apps/website/ui/AuthError.tsx
Normal file
18
apps/website/ui/AuthError.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { ErrorBanner } from './ErrorBanner';
|
||||
|
||||
interface AuthErrorProps {
|
||||
action: string;
|
||||
}
|
||||
|
||||
export function AuthError({ action }: AuthErrorProps) {
|
||||
return (
|
||||
<ErrorBanner
|
||||
message={`Failed to load ${action} page`}
|
||||
title="Error"
|
||||
variant="error"
|
||||
/>
|
||||
);
|
||||
}
|
||||
22
apps/website/ui/AuthLoading.tsx
Normal file
22
apps/website/ui/AuthLoading.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
import { LoadingSpinner } from './LoadingSpinner';
|
||||
|
||||
interface AuthLoadingProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function AuthLoading({ message = 'Authenticating...' }: AuthLoadingProps) {
|
||||
return (
|
||||
<Box style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#0f1115' }}>
|
||||
<Stack align="center" gap={4}>
|
||||
<LoadingSpinner size={12} />
|
||||
<Text color="text-gray-400">{message}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
25
apps/website/ui/Badge.tsx
Normal file
25
apps/website/ui/Badge.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './Box';
|
||||
|
||||
interface BadgeProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
}
|
||||
|
||||
export function Badge({ children, className = '', variant = 'default' }: BadgeProps) {
|
||||
const baseClasses = 'flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium';
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-gray-500/10 border-gray-500/30 text-gray-400',
|
||||
primary: 'bg-primary-blue/10 border-primary-blue/30 text-primary-blue',
|
||||
success: 'bg-performance-green/10 border-performance-green/30 text-performance-green',
|
||||
warning: 'bg-warning-amber/10 border-warning-amber/30 text-warning-amber',
|
||||
danger: 'bg-red-600/10 border-red-600/30 text-red-500',
|
||||
info: 'bg-neon-aqua/10 border-neon-aqua/30 text-neon-aqua'
|
||||
};
|
||||
|
||||
const classes = [baseClasses, variantClasses[variant], className].filter(Boolean).join(' ');
|
||||
|
||||
return <Box className={classes}>{children}</Box>;
|
||||
}
|
||||
93
apps/website/ui/Box.tsx
Normal file
93
apps/website/ui/Box.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { forwardRef, ForwardedRef, ElementType, ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
|
||||
|
||||
interface BoxProps<T extends ElementType> {
|
||||
as?: T;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
center?: boolean;
|
||||
fullWidth?: boolean;
|
||||
fullHeight?: boolean;
|
||||
m?: Spacing;
|
||||
mt?: Spacing;
|
||||
mb?: Spacing;
|
||||
ml?: Spacing;
|
||||
mr?: Spacing;
|
||||
mx?: Spacing | 'auto';
|
||||
my?: Spacing;
|
||||
p?: Spacing;
|
||||
pt?: Spacing;
|
||||
pb?: Spacing;
|
||||
pl?: Spacing;
|
||||
pr?: Spacing;
|
||||
px?: Spacing;
|
||||
py?: Spacing;
|
||||
display?: 'block' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'none';
|
||||
position?: 'relative' | 'absolute' | 'fixed' | 'sticky';
|
||||
overflow?: 'visible' | 'hidden' | 'scroll' | 'auto';
|
||||
maxWidth?: string;
|
||||
}
|
||||
|
||||
export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
{
|
||||
as,
|
||||
children,
|
||||
className = '',
|
||||
center = false,
|
||||
fullWidth = false,
|
||||
fullHeight = false,
|
||||
m, mt, mb, ml, mr, mx, my,
|
||||
p, pt, pb, pl, pr, px, py,
|
||||
display,
|
||||
position,
|
||||
overflow,
|
||||
maxWidth,
|
||||
...props
|
||||
}: BoxProps<T> & ComponentPropsWithoutRef<T>,
|
||||
ref: ForwardedRef<any>
|
||||
) => {
|
||||
const Tag = (as as any) || 'div';
|
||||
|
||||
const spacingMap: Record<string | number, string> = {
|
||||
0: '0', 0.5: '0.5', 1: '1', 1.5: '1.5', 2: '2', 2.5: '2.5', 3: '3', 3.5: '3.5', 4: '4',
|
||||
5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: '11', 12: '12', 14: '14',
|
||||
16: '16', 20: '20', 24: '24', 28: '28', 32: '32', 36: '36', 40: '40', 44: '44',
|
||||
48: '48', 52: '52', 56: '56', 60: '60', 64: '64', 72: '72', 80: '80', 96: '96',
|
||||
'auto': 'auto'
|
||||
};
|
||||
|
||||
const classes = [
|
||||
center ? 'flex items-center justify-center' : '',
|
||||
fullWidth ? 'w-full' : '',
|
||||
fullHeight ? 'h-full' : '',
|
||||
m !== undefined ? `m-${spacingMap[m]}` : '',
|
||||
mt !== undefined ? `mt-${spacingMap[mt]}` : '',
|
||||
mb !== undefined ? `mb-${spacingMap[mb]}` : '',
|
||||
ml !== undefined ? `ml-${spacingMap[ml]}` : '',
|
||||
mr !== undefined ? `mr-${spacingMap[mr]}` : '',
|
||||
mx !== undefined ? `mx-${spacingMap[mx]}` : '',
|
||||
my !== undefined ? `my-${spacingMap[my]}` : '',
|
||||
p !== undefined ? `p-${spacingMap[p]}` : '',
|
||||
pt !== undefined ? `pt-${spacingMap[pt]}` : '',
|
||||
pb !== undefined ? `pb-${spacingMap[pb]}` : '',
|
||||
pl !== undefined ? `pl-${spacingMap[pl]}` : '',
|
||||
pr !== undefined ? `pr-${spacingMap[pr]}` : '',
|
||||
px !== undefined ? `px-${spacingMap[px]}` : '',
|
||||
py !== undefined ? `py-${spacingMap[py]}` : '',
|
||||
display ? display : '',
|
||||
position ? position : '',
|
||||
overflow ? `overflow-${overflow}` : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const style = maxWidth ? { maxWidth, ...((props as any).style || {}) } : (props as any).style;
|
||||
|
||||
return (
|
||||
<Tag ref={ref} className={classes} {...props} style={style}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
});
|
||||
|
||||
Box.displayName = 'Box';
|
||||
@@ -1,13 +1,18 @@
|
||||
import React, { ReactNode, MouseEventHandler } from 'react';
|
||||
import React, { ReactNode, MouseEventHandler, ButtonHTMLAttributes } from 'react';
|
||||
import { Stack } from './Stack';
|
||||
|
||||
interface ButtonProps {
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
className?: string;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'race-performance' | 'race-final';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
disabled?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
icon?: ReactNode;
|
||||
fullWidth?: boolean;
|
||||
as?: 'button' | 'a';
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
@@ -17,15 +22,22 @@ export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
type = 'button'
|
||||
type = 'button',
|
||||
icon,
|
||||
fullWidth = false,
|
||||
as = 'button',
|
||||
href,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseClasses = 'inline-flex items-center rounded-lg transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2';
|
||||
const baseClasses = 'inline-flex items-center rounded-lg transition-all duration-75 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 hover:scale-[1.02] active:scale-95';
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-primary-blue text-white hover:bg-primary-blue/80 focus-visible:outline-primary-blue',
|
||||
primary: 'bg-primary-blue text-white hover:bg-primary-blue/80 focus-visible:outline-primary-blue shadow-[0_0_15px_rgba(25,140,255,0.4)]',
|
||||
secondary: 'bg-iron-gray text-white border border-charcoal-outline hover:bg-iron-gray/80 focus-visible:outline-primary-blue',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600',
|
||||
ghost: 'bg-transparent text-gray-400 hover:bg-gray-800 focus-visible:outline-gray-400'
|
||||
ghost: 'bg-transparent text-gray-400 hover:bg-gray-800 focus-visible:outline-gray-400',
|
||||
'race-performance': 'bg-gradient-to-r from-yellow-400 to-orange-500 text-white shadow-[0_0_15px_rgba(251,191,36,0.4)] hover:from-yellow-500 hover:to-orange-600 focus-visible:outline-yellow-400',
|
||||
'race-final': 'bg-gradient-to-r from-purple-400 to-pink-500 text-white shadow-[0_0_15px_rgba(168,85,247,0.4)] hover:from-purple-500 hover:to-pink-600 focus-visible:outline-purple-400'
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
@@ -35,23 +47,45 @@ export function Button({
|
||||
};
|
||||
|
||||
const disabledClasses = disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer';
|
||||
const widthClasses = fullWidth ? 'w-full' : '';
|
||||
|
||||
const classes = [
|
||||
baseClasses,
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
disabledClasses,
|
||||
widthClasses,
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const content = icon ? (
|
||||
<Stack direction="row" align="center" gap={2} center={fullWidth}>
|
||||
{icon}
|
||||
{children}
|
||||
</Stack>
|
||||
) : children;
|
||||
|
||||
if (as === 'a') {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={classes}
|
||||
{...(props as React.AnchorHTMLAttributes<HTMLAnchorElement>)}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={classes}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,60 @@
|
||||
import React, { ReactNode, MouseEventHandler } from 'react';
|
||||
import React, { ReactNode, MouseEventHandler, HTMLAttributes } from 'react';
|
||||
|
||||
interface CardProps {
|
||||
type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||
variant?: 'default' | 'highlight';
|
||||
p?: Spacing;
|
||||
px?: Spacing;
|
||||
py?: Spacing;
|
||||
pt?: Spacing;
|
||||
pb?: Spacing;
|
||||
pl?: Spacing;
|
||||
pr?: Spacing;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className = '',
|
||||
onClick,
|
||||
variant = 'default'
|
||||
variant = 'default',
|
||||
p, px, py, pt, pb, pl, pr,
|
||||
...props
|
||||
}: CardProps) {
|
||||
const baseClasses = 'rounded-lg p-6 shadow-card border duration-200';
|
||||
const baseClasses = 'rounded-lg shadow-card border duration-200';
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-iron-gray border-charcoal-outline',
|
||||
highlight: 'bg-gradient-to-r from-blue-900/20 to-blue-700/10 border-blue-500/30'
|
||||
};
|
||||
|
||||
const spacingMap: Record<number, string> = {
|
||||
0: '0', 0.5: '0.5', 1: '1', 1.5: '1.5', 2: '2', 2.5: '2.5', 3: '3', 3.5: '3.5', 4: '4',
|
||||
5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: '11', 12: '12', 14: '14',
|
||||
16: '16', 20: '20', 24: '24', 28: '28', 32: '32', 36: '36', 40: '40', 44: '44',
|
||||
48: '48', 52: '52', 56: '56', 60: '60', 64: '64', 72: '72', 80: '80', 96: '96'
|
||||
};
|
||||
|
||||
const classes = [
|
||||
baseClasses,
|
||||
variantClasses[variant],
|
||||
onClick ? 'cursor-pointer hover:scale-[1.02]' : '',
|
||||
p !== undefined ? `p-${spacingMap[p]}` : (px === undefined && py === undefined && pt === undefined && pb === undefined && pl === undefined && pr === undefined ? 'p-6' : ''),
|
||||
px !== undefined ? `px-${spacingMap[px]}` : '',
|
||||
py !== undefined ? `py-${spacingMap[py]}` : '',
|
||||
pt !== undefined ? `pt-${spacingMap[pt]}` : '',
|
||||
pb !== undefined ? `pb-${spacingMap[pb]}` : '',
|
||||
pl !== undefined ? `pl-${spacingMap[pl]}` : '',
|
||||
pr !== undefined ? `pr-${spacingMap[pr]}` : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className={classes} onClick={onClick}>
|
||||
<div className={classes} onClick={onClick} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
50
apps/website/ui/Container.tsx
Normal file
50
apps/website/ui/Container.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { ReactNode, HTMLAttributes } from 'react';
|
||||
import { Box } from './Box';
|
||||
|
||||
type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
|
||||
|
||||
interface ContainerProps extends HTMLAttributes<HTMLElement> {
|
||||
children: ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
padding?: boolean;
|
||||
className?: string;
|
||||
py?: Spacing;
|
||||
}
|
||||
|
||||
export function Container({
|
||||
children,
|
||||
size = 'lg',
|
||||
padding = true,
|
||||
className = '',
|
||||
py,
|
||||
...props
|
||||
}: ContainerProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-2xl',
|
||||
md: 'max-w-4xl',
|
||||
lg: 'max-w-7xl',
|
||||
xl: 'max-w-[1400px]',
|
||||
full: 'max-w-full'
|
||||
};
|
||||
|
||||
const spacingMap: Record<number, string> = {
|
||||
0: '0', 0.5: '0.5', 1: '1', 1.5: '1.5', 2: '2', 2.5: '2.5', 3: '3', 3.5: '3.5', 4: '4',
|
||||
5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: '11', 12: '12', 14: '14',
|
||||
16: '16', 20: '20', 24: '24', 28: '28', 32: '32', 36: '36', 40: '40', 44: '44',
|
||||
48: '48', 52: '52', 56: '56', 60: '60', 64: '64', 72: '72', 80: '80', 96: '96'
|
||||
};
|
||||
|
||||
const classes = [
|
||||
'mx-auto',
|
||||
sizeClasses[size],
|
||||
padding ? 'px-4 sm:px-6 lg:px-8' : '',
|
||||
py !== undefined ? `py-${spacingMap[py]}` : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<Box className={classes} {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
108
apps/website/ui/CountryFlag.tsx
Normal file
108
apps/website/ui/CountryFlag.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
// ISO 3166-1 alpha-2 country code to full country name mapping
|
||||
const countryNames: Record<string, string> = {
|
||||
'US': 'United States',
|
||||
'GB': 'United Kingdom',
|
||||
'CA': 'Canada',
|
||||
'AU': 'Australia',
|
||||
'NZ': 'New Zealand',
|
||||
'DE': 'Germany',
|
||||
'FR': 'France',
|
||||
'IT': 'Italy',
|
||||
'ES': 'Spain',
|
||||
'NL': 'Netherlands',
|
||||
'BE': 'Belgium',
|
||||
'SE': 'Sweden',
|
||||
'NO': 'Norway',
|
||||
'DK': 'Denmark',
|
||||
'FI': 'Finland',
|
||||
'PL': 'Poland',
|
||||
'CZ': 'Czech Republic',
|
||||
'AT': 'Austria',
|
||||
'CH': 'Switzerland',
|
||||
'PT': 'Portugal',
|
||||
'IE': 'Ireland',
|
||||
'BR': 'Brazil',
|
||||
'MX': 'Mexico',
|
||||
'AR': 'Argentina',
|
||||
'JP': 'Japan',
|
||||
'CN': 'China',
|
||||
'KR': 'South Korea',
|
||||
'IN': 'India',
|
||||
'SG': 'Singapore',
|
||||
'TH': 'Thailand',
|
||||
'MY': 'Malaysia',
|
||||
'ID': 'Indonesia',
|
||||
'PH': 'Philippines',
|
||||
'ZA': 'South Africa',
|
||||
'RU': 'Russia',
|
||||
'MC': 'Monaco',
|
||||
'TR': 'Turkey',
|
||||
'GR': 'Greece',
|
||||
'HU': 'Hungary',
|
||||
'RO': 'Romania',
|
||||
'BG': 'Bulgaria',
|
||||
'HR': 'Croatia',
|
||||
'SI': 'Slovenia',
|
||||
'SK': 'Slovakia',
|
||||
'LT': 'Lithuania',
|
||||
'LV': 'Latvia',
|
||||
'EE': 'Estonia',
|
||||
};
|
||||
|
||||
// ISO 3166-1 alpha-2 country code to flag emoji conversion
|
||||
const countryCodeToFlag = (countryCode: string): string => {
|
||||
if (!countryCode || countryCode.length !== 2) return '🏁';
|
||||
|
||||
// Convert ISO 3166-1 alpha-2 to regional indicator symbols
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(char => 127397 + char.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
};
|
||||
|
||||
interface CountryFlagProps {
|
||||
countryCode: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
showTooltip?: boolean;
|
||||
}
|
||||
|
||||
export function CountryFlag({
|
||||
countryCode,
|
||||
size = 'md',
|
||||
className = '',
|
||||
showTooltip = true
|
||||
}: CountryFlagProps) {
|
||||
const [showTooltipState, setShowTooltipState] = useState(false);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base'
|
||||
};
|
||||
|
||||
const flag = countryCodeToFlag(countryCode);
|
||||
const countryName = countryNames[countryCode.toUpperCase()] || countryCode;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center relative ${sizeClasses[size]} ${className}`}
|
||||
onMouseEnter={() => setShowTooltipState(true)}
|
||||
onMouseLeave={() => setShowTooltipState(false)}
|
||||
title={showTooltip ? countryName : undefined}
|
||||
>
|
||||
<span className="select-none">{flag}</span>
|
||||
{showTooltip && showTooltipState && (
|
||||
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 text-xs font-medium text-white bg-deep-graphite border border-charcoal-outline rounded shadow-lg whitespace-nowrap z-50">
|
||||
{countryName}
|
||||
<span className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-charcoal-outline"></span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
191
apps/website/ui/CountrySelect.tsx
Normal file
191
apps/website/ui/CountrySelect.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Globe, Search, ChevronDown, Check } from 'lucide-react';
|
||||
import { CountryFlag } from './CountryFlag';
|
||||
|
||||
export interface Country {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const COUNTRIES: Country[] = [
|
||||
{ code: 'US', name: 'United States' },
|
||||
{ code: 'GB', name: 'United Kingdom' },
|
||||
{ code: 'DE', name: 'Germany' },
|
||||
{ code: 'NL', name: 'Netherlands' },
|
||||
{ code: 'FR', name: 'France' },
|
||||
{ code: 'IT', name: 'Italy' },
|
||||
{ code: 'ES', name: 'Spain' },
|
||||
{ code: 'AU', name: 'Australia' },
|
||||
{ code: 'CA', name: 'Canada' },
|
||||
{ code: 'BR', name: 'Brazil' },
|
||||
{ code: 'JP', name: 'Japan' },
|
||||
{ code: 'BE', name: 'Belgium' },
|
||||
{ code: 'AT', name: 'Austria' },
|
||||
{ code: 'CH', name: 'Switzerland' },
|
||||
{ code: 'SE', name: 'Sweden' },
|
||||
{ code: 'NO', name: 'Norway' },
|
||||
{ code: 'DK', name: 'Denmark' },
|
||||
{ code: 'FI', name: 'Finland' },
|
||||
{ code: 'PL', name: 'Poland' },
|
||||
{ code: 'PT', name: 'Portugal' },
|
||||
{ code: 'CZ', name: 'Czech Republic' },
|
||||
{ code: 'HU', name: 'Hungary' },
|
||||
{ code: 'RU', name: 'Russia' },
|
||||
{ code: 'MX', name: 'Mexico' },
|
||||
{ code: 'AR', name: 'Argentina' },
|
||||
{ code: 'CL', name: 'Chile' },
|
||||
{ code: 'NZ', name: 'New Zealand' },
|
||||
{ code: 'ZA', name: 'South Africa' },
|
||||
{ code: 'IN', name: 'India' },
|
||||
{ code: 'KR', name: 'South Korea' },
|
||||
{ code: 'SG', name: 'Singapore' },
|
||||
{ code: 'MY', name: 'Malaysia' },
|
||||
{ code: 'TH', name: 'Thailand' },
|
||||
{ code: 'AE', name: 'United Arab Emirates' },
|
||||
{ code: 'SA', name: 'Saudi Arabia' },
|
||||
{ code: 'IE', name: 'Ireland' },
|
||||
{ code: 'GR', name: 'Greece' },
|
||||
{ code: 'TR', name: 'Turkey' },
|
||||
{ code: 'RO', name: 'Romania' },
|
||||
{ code: 'UA', name: 'Ukraine' },
|
||||
];
|
||||
|
||||
interface CountrySelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: boolean;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function CountrySelect({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
errorMessage,
|
||||
disabled,
|
||||
placeholder = 'Select country',
|
||||
}: CountrySelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const selectedCountry = COUNTRIES.find(c => c.code === value);
|
||||
|
||||
const filteredCountries = COUNTRIES.filter(country =>
|
||||
country.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
country.code.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSelect = (code: string) => {
|
||||
onChange(code);
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
{/* Trigger Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
className={`flex items-center justify-between w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset transition-all duration-150 sm:text-sm ${
|
||||
error
|
||||
? 'ring-warning-amber focus:ring-warning-amber'
|
||||
: 'ring-charcoal-outline focus:ring-primary-blue'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:ring-gray-500'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="w-4 h-4 text-gray-500" />
|
||||
{selectedCountry ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<CountryFlag countryCode={selectedCountry.code} size="md" showTooltip={false} />
|
||||
<span>{selectedCountry.name}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-500">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-2 w-full rounded-lg bg-iron-gray border border-charcoal-outline shadow-xl max-h-80 overflow-hidden">
|
||||
{/* Search Input */}
|
||||
<div className="p-2 border-b border-charcoal-outline">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search countries..."
|
||||
className="w-full rounded-md border-0 px-4 py-2 pl-9 bg-deep-graphite text-white text-sm placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-primary-blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Country List */}
|
||||
<div className="overflow-y-auto max-h-60">
|
||||
{filteredCountries.length > 0 ? (
|
||||
filteredCountries.map((country) => (
|
||||
<button
|
||||
key={country.code}
|
||||
type="button"
|
||||
onClick={() => handleSelect(country.code)}
|
||||
className={`flex items-center justify-between w-full px-4 py-2.5 text-left text-sm transition-colors ${
|
||||
value === country.code
|
||||
? 'bg-primary-blue/20 text-white'
|
||||
: 'text-gray-300 hover:bg-deep-graphite'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<CountryFlag countryCode={country.code} size="md" showTooltip={false} />
|
||||
<span>{country.name}</span>
|
||||
</span>
|
||||
{value === country.code && (
|
||||
<Check className="w-4 h-4 text-primary-blue" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-6 text-center text-gray-500 text-sm">
|
||||
No countries found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && errorMessage && (
|
||||
<p className="mt-2 text-sm text-warning-amber">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
apps/website/ui/DecorativeBlur.tsx
Normal file
46
apps/website/ui/DecorativeBlur.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
|
||||
interface DecorativeBlurProps {
|
||||
color?: 'blue' | 'green' | 'purple' | 'yellow' | 'red';
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
position?: 'top-right' | 'bottom-left' | 'center';
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export function DecorativeBlur({
|
||||
color = 'blue',
|
||||
size = 'md',
|
||||
position = 'center',
|
||||
opacity = 10
|
||||
}: DecorativeBlurProps) {
|
||||
const colorClasses = {
|
||||
blue: 'bg-primary-blue',
|
||||
green: 'bg-performance-green',
|
||||
purple: 'bg-purple-600',
|
||||
yellow: 'bg-yellow-400',
|
||||
red: 'bg-racing-red'
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-32 h-32 blur-xl',
|
||||
md: 'w-48 h-48 blur-2xl',
|
||||
lg: 'w-64 h-64 blur-3xl',
|
||||
xl: 'w-96 h-96 blur-[64px]'
|
||||
};
|
||||
|
||||
const positionClasses = {
|
||||
'top-right': 'absolute top-0 right-0',
|
||||
'bottom-left': 'absolute bottom-0 left-0',
|
||||
'center': 'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'
|
||||
};
|
||||
|
||||
const opacityStyle = { opacity: opacity / 100 };
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={`${colorClasses[color]} ${sizeClasses[size]} ${positionClasses[position]} rounded-full pointer-events-none`}
|
||||
style={opacityStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
71
apps/website/ui/DurationField.tsx
Normal file
71
apps/website/ui/DurationField.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import Input from '@/ui/Input';
|
||||
|
||||
interface DurationFieldProps {
|
||||
label: string;
|
||||
value: number | '';
|
||||
onChange: (value: number | '') => void;
|
||||
helperText?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
unit?: 'minutes' | 'laps';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function DurationField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
helperText,
|
||||
required,
|
||||
disabled,
|
||||
unit = 'minutes',
|
||||
error,
|
||||
}: DurationFieldProps) {
|
||||
const handleChange = (raw: string) => {
|
||||
if (raw.trim() === '') {
|
||||
onChange('');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseInt(raw, 10);
|
||||
if (Number.isNaN(parsed) || parsed <= 0) {
|
||||
onChange('');
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(parsed);
|
||||
};
|
||||
|
||||
const unitLabel = unit === 'laps' ? 'laps' : 'min';
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
{label}
|
||||
{required && <span className="text-warning-amber ml-1">*</span>}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={value === '' ? '' : String(value)}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
className="pr-16"
|
||||
error={!!error}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 -ml-14">{unitLabel}</span>
|
||||
</div>
|
||||
{helperText && (
|
||||
<p className="text-xs text-gray-500">{helperText}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-xs text-warning-amber mt-1">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
apps/website/ui/ErrorBanner.tsx
Normal file
37
apps/website/ui/ErrorBanner.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Surface } from './Surface';
|
||||
|
||||
export interface ErrorBannerProps {
|
||||
message: string;
|
||||
title?: string;
|
||||
variant?: 'error' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
export function ErrorBanner({ message, title, variant = 'error' }: ErrorBannerProps) {
|
||||
const variantColors = {
|
||||
error: { bg: 'rgba(239, 68, 68, 0.1)', border: '#ef4444', text: '#ef4444' },
|
||||
warning: { bg: 'rgba(245, 158, 11, 0.1)', border: '#f59e0b', text: '#fcd34d' },
|
||||
info: { bg: 'rgba(59, 130, 246, 0.1)', border: '#3b82f6', text: '#3b82f6' },
|
||||
};
|
||||
|
||||
const colors = variantColors[variant];
|
||||
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
border
|
||||
padding={4}
|
||||
style={{ backgroundColor: colors.bg, borderColor: colors.border }}
|
||||
>
|
||||
<Box style={{ flex: 1 }}>
|
||||
{title && <Text weight="medium" style={{ color: colors.text }} block mb={1}>{title}</Text>}
|
||||
<Text size="sm" style={{ color: colors.text, opacity: 0.9 }} block>{message}</Text>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
45
apps/website/ui/FormField.tsx
Normal file
45
apps/website/ui/FormField.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface FormFieldProps {
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
children: React.ReactNode;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function FormField({
|
||||
label,
|
||||
icon,
|
||||
children,
|
||||
required = false,
|
||||
error,
|
||||
hint,
|
||||
}: FormFieldProps) {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
{icon && <Icon icon={icon} size={4} color="#6b7280" />}
|
||||
<Text size="sm" weight="medium" color="text-gray-300">{label}</Text>
|
||||
{required && <Text color="text-error-red">*</Text>}
|
||||
</Stack>
|
||||
</label>
|
||||
{children}
|
||||
{error && (
|
||||
<Text size="xs" color="text-error-red" block mt={1}>{error}</Text>
|
||||
)}
|
||||
{hint && !error && (
|
||||
<Text size="xs" color="text-gray-500" block mt={1}>{hint}</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
52
apps/website/ui/Grid.tsx
Normal file
52
apps/website/ui/Grid.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { ReactNode, HTMLAttributes } from 'react';
|
||||
import { Box } from './Box';
|
||||
|
||||
interface GridProps extends HTMLAttributes<HTMLElement> {
|
||||
children: ReactNode;
|
||||
cols?: 1 | 2 | 3 | 4 | 5 | 6 | 12;
|
||||
gap?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Grid({
|
||||
children,
|
||||
cols = 1,
|
||||
gap = 4,
|
||||
className = '',
|
||||
...props
|
||||
}: GridProps) {
|
||||
const colClasses: Record<number, string> = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-3',
|
||||
4: 'grid-cols-2 md:grid-cols-4',
|
||||
5: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-5',
|
||||
6: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-6',
|
||||
12: 'grid-cols-12'
|
||||
};
|
||||
|
||||
const gapClasses: Record<number, string> = {
|
||||
0: 'gap-0',
|
||||
1: 'gap-1',
|
||||
2: 'gap-2',
|
||||
3: 'gap-3',
|
||||
4: 'gap-4',
|
||||
6: 'gap-6',
|
||||
8: 'gap-8',
|
||||
12: 'gap-12',
|
||||
16: 'gap-16'
|
||||
};
|
||||
|
||||
const classes = [
|
||||
'grid',
|
||||
colClasses[cols] || 'grid-cols-1',
|
||||
gapClasses[gap] || 'gap-4',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<Box className={classes} {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
25
apps/website/ui/GridItem.tsx
Normal file
25
apps/website/ui/GridItem.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
|
||||
interface GridItemProps {
|
||||
children: React.ReactNode;
|
||||
colSpan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
mdSpan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
lgSpan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GridItem({ children, colSpan, mdSpan, lgSpan, className = '' }: GridItemProps) {
|
||||
const spanClasses = [
|
||||
colSpan ? `col-span-${colSpan}` : '',
|
||||
mdSpan ? `md:col-span-${mdSpan}` : '',
|
||||
lgSpan ? `lg:col-span-${lgSpan}` : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<Box className={spanClasses}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import Container from '@/components/ui/Container';
|
||||
import Container from '@/ui/Container';
|
||||
|
||||
interface HeaderProps {
|
||||
children: React.ReactNode;
|
||||
|
||||
34
apps/website/ui/Heading.tsx
Normal file
34
apps/website/ui/Heading.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React, { ReactNode, HTMLAttributes } from 'react';
|
||||
import { Stack } from './Stack';
|
||||
|
||||
interface HeadingProps extends HTMLAttributes<HTMLHeadingElement> {
|
||||
level: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export function Heading({ level, children, className = '', style, icon, ...props }: HeadingProps) {
|
||||
const Tag = `h${level}` as 'h1';
|
||||
|
||||
const levelClasses = {
|
||||
1: 'text-3xl md:text-4xl font-bold text-white',
|
||||
2: 'text-xl font-semibold text-white',
|
||||
3: 'text-lg font-semibold text-white',
|
||||
4: 'text-base font-semibold text-white',
|
||||
5: 'text-sm font-semibold text-white',
|
||||
6: 'text-xs font-semibold text-white',
|
||||
};
|
||||
|
||||
const classes = [levelClasses[level], className].filter(Boolean).join(' ');
|
||||
|
||||
const content = icon ? (
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
{icon}
|
||||
{children}
|
||||
</Stack>
|
||||
) : children;
|
||||
|
||||
return <Tag className={classes} style={style} {...props}>{content}</Tag>;
|
||||
}
|
||||
30
apps/website/ui/Hero.tsx
Normal file
30
apps/website/ui/Hero.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './Box';
|
||||
|
||||
interface HeroProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
variant?: 'default' | 'primary' | 'secondary';
|
||||
}
|
||||
|
||||
export function Hero({ children, className = '', variant = 'default' }: HeroProps) {
|
||||
const baseClasses = 'relative overflow-hidden rounded-2xl border p-8';
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-iron-gray border-charcoal-outline',
|
||||
primary: 'bg-gradient-to-br from-iron-gray via-iron-gray to-charcoal-outline border-charcoal-outline',
|
||||
secondary: 'bg-gradient-to-br from-primary-blue/10 to-purple-600/10 border-primary-blue/20'
|
||||
};
|
||||
|
||||
const classes = [baseClasses, variantClasses[variant], className].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<Box className={classes}>
|
||||
<Box className="absolute top-0 right-0 w-64 h-64 bg-primary-blue/5 rounded-full blur-3xl" />
|
||||
<Box className="absolute bottom-0 left-0 w-48 h-48 bg-performance-green/5 rounded-full blur-3xl" />
|
||||
<Box className="relative z-10">
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
37
apps/website/ui/Icon.tsx
Normal file
37
apps/website/ui/Icon.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface IconProps {
|
||||
icon: LucideIcon;
|
||||
size?: number | string;
|
||||
color?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function Icon({ icon: LucideIcon, size = 4, color, className = '', style, ...props }: IconProps) {
|
||||
const sizeMap: Record<string | number, string> = {
|
||||
3: 'w-3 h-3',
|
||||
3.5: 'w-3.5 h-3.5',
|
||||
4: 'w-4 h-4',
|
||||
5: 'w-5 h-5',
|
||||
6: 'w-6 h-6',
|
||||
7: 'w-7 h-7',
|
||||
8: 'w-8 h-8',
|
||||
10: 'w-10 h-10',
|
||||
12: 'w-12 h-12',
|
||||
16: 'w-16 h-16'
|
||||
};
|
||||
|
||||
const sizeClass = sizeMap[size] || 'w-4 h-4';
|
||||
|
||||
const combinedStyle = color ? { color, ...style } : style;
|
||||
|
||||
return (
|
||||
<LucideIcon
|
||||
className={`${sizeClass} ${className}`}
|
||||
style={combinedStyle}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
23
apps/website/ui/Image.tsx
Normal file
23
apps/website/ui/Image.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React, { ImgHTMLAttributes } from 'react';
|
||||
|
||||
interface ImageProps extends ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
alt: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Image({ src, alt, width, height, className = '', ...props }: ImageProps) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
85
apps/website/ui/InfoBanner.tsx
Normal file
85
apps/website/ui/InfoBanner.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Info, AlertTriangle, CheckCircle, XCircle, LucideIcon } from 'lucide-react';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
import { Surface } from './Surface';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
type BannerType = 'info' | 'warning' | 'success' | 'error';
|
||||
|
||||
interface InfoBannerProps {
|
||||
type?: BannerType;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
export function InfoBanner({
|
||||
type = 'info',
|
||||
title,
|
||||
children,
|
||||
icon: CustomIcon,
|
||||
}: InfoBannerProps) {
|
||||
const bannerConfig: Record<BannerType, {
|
||||
icon: LucideIcon;
|
||||
bg: string;
|
||||
border: string;
|
||||
titleColor: string;
|
||||
iconColor: string;
|
||||
}> = {
|
||||
info: {
|
||||
icon: Info,
|
||||
bg: 'rgba(38, 38, 38, 0.3)',
|
||||
border: 'rgba(38, 38, 38, 0.5)',
|
||||
titleColor: 'text-gray-300',
|
||||
iconColor: '#9ca3af',
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
bg: 'rgba(245, 158, 11, 0.1)',
|
||||
border: 'rgba(245, 158, 11, 0.3)',
|
||||
titleColor: 'text-warning-amber',
|
||||
iconColor: '#f59e0b',
|
||||
},
|
||||
success: {
|
||||
icon: CheckCircle,
|
||||
bg: 'rgba(16, 185, 129, 0.1)',
|
||||
border: 'rgba(16, 185, 129, 0.3)',
|
||||
titleColor: 'text-performance-green',
|
||||
iconColor: '#10b981',
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
bg: 'rgba(239, 68, 68, 0.1)',
|
||||
border: 'rgba(239, 68, 68, 0.3)',
|
||||
titleColor: 'text-error-red',
|
||||
iconColor: '#ef4444',
|
||||
},
|
||||
};
|
||||
|
||||
const config = bannerConfig[type];
|
||||
const BannerIcon = CustomIcon || config.icon;
|
||||
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
border
|
||||
padding={4}
|
||||
style={{ backgroundColor: config.bg, borderColor: config.border }}
|
||||
>
|
||||
<Stack direction="row" align="start" gap={3}>
|
||||
<Icon icon={BannerIcon} size={5} color={config.iconColor} />
|
||||
<Box style={{ flex: 1 }}>
|
||||
{title && (
|
||||
<Text weight="medium" color={config.titleColor as any} block mb={1}>{title}</Text>
|
||||
)}
|
||||
<Text size="sm" color="text-gray-400" block>{children}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
65
apps/website/ui/InfoBox.tsx
Normal file
65
apps/website/ui/InfoBox.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { Surface } from './Surface';
|
||||
import { Stack } from './Stack';
|
||||
import { Box } from './Box';
|
||||
import { Icon } from './Icon';
|
||||
import { Text } from './Text';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface InfoBoxProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
variant?: 'primary' | 'success' | 'warning' | 'default';
|
||||
}
|
||||
|
||||
export function InfoBox({ icon, title, description, variant = 'default' }: InfoBoxProps) {
|
||||
const variantColors = {
|
||||
primary: {
|
||||
bg: 'rgba(59, 130, 246, 0.1)',
|
||||
border: '#3b82f6',
|
||||
text: '#3b82f6',
|
||||
icon: '#3b82f6'
|
||||
},
|
||||
success: {
|
||||
bg: 'rgba(16, 185, 129, 0.1)',
|
||||
border: '#10b981',
|
||||
text: '#10b981',
|
||||
icon: '#10b981'
|
||||
},
|
||||
warning: {
|
||||
bg: 'rgba(245, 158, 11, 0.1)',
|
||||
border: '#f59e0b',
|
||||
text: '#f59e0b',
|
||||
icon: '#f59e0b'
|
||||
},
|
||||
default: {
|
||||
bg: 'rgba(38, 38, 38, 0.3)',
|
||||
border: '#262626',
|
||||
text: 'white',
|
||||
icon: '#9ca3af'
|
||||
}
|
||||
};
|
||||
|
||||
const colors = variantColors[variant];
|
||||
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
border
|
||||
padding={4}
|
||||
style={{ backgroundColor: colors.bg, borderColor: colors.border }}
|
||||
>
|
||||
<Stack direction="row" align="start" gap={3}>
|
||||
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(255, 255, 255, 0.05)' }}>
|
||||
<Icon icon={icon} size={5} color={colors.icon} />
|
||||
</Surface>
|
||||
<Box>
|
||||
<Text weight="medium" style={{ color: colors.text }} block>{title}</Text>
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>{description}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,28 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Text } from './Text';
|
||||
import { Box } from './Box';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
variant?: 'default' | 'error';
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className = '', variant = 'default', ...props }, ref) => {
|
||||
const baseClasses = 'px-3 py-2 border rounded-lg text-white bg-deep-graphite focus:outline-none focus:border-primary-blue transition-colors';
|
||||
const variantClasses = variant === 'error' ? 'border-racing-red' : 'border-charcoal-outline';
|
||||
({ className = '', variant = 'default', errorMessage, ...props }, ref) => {
|
||||
const baseClasses = 'px-3 py-2 border rounded-lg text-white bg-deep-graphite focus:outline-none focus:border-primary-blue transition-colors w-full';
|
||||
const variantClasses = (variant === 'error' || errorMessage) ? 'border-racing-red' : 'border-charcoal-outline';
|
||||
const classes = `${baseClasses} ${variantClasses} ${className}`;
|
||||
|
||||
return <input ref={ref} className={classes} {...props} />;
|
||||
return (
|
||||
<Box fullWidth>
|
||||
<input ref={ref} className={classes} {...props} />
|
||||
{errorMessage && (
|
||||
<Text size="xs" color="text-error-red" block mt={1}>
|
||||
{errorMessage}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import NextLink from 'next/link';
|
||||
import React, { ReactNode, AnchorHTMLAttributes } from 'react';
|
||||
|
||||
interface LinkProps {
|
||||
interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
target?: '_blank' | '_self' | '_parent' | '_top';
|
||||
rel?: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function Link({
|
||||
@@ -16,7 +17,10 @@ export function Link({
|
||||
className = '',
|
||||
variant = 'primary',
|
||||
target = '_self',
|
||||
rel = ''
|
||||
rel = '',
|
||||
onClick,
|
||||
style,
|
||||
...props
|
||||
}: LinkProps) {
|
||||
const baseClasses = 'inline-flex items-center transition-colors';
|
||||
|
||||
@@ -33,13 +37,16 @@ export function Link({
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<NextLink
|
||||
<a
|
||||
href={href}
|
||||
className={classes}
|
||||
target={target}
|
||||
rel={rel}
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</NextLink>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
27
apps/website/ui/LoadingSpinner.tsx
Normal file
27
apps/website/ui/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ size = 8, color = '#3b82f6', className = '' }: LoadingSpinnerProps) {
|
||||
const style: React.CSSProperties = {
|
||||
width: `${size * 0.25}rem`,
|
||||
height: `${size * 0.25}rem`,
|
||||
border: '2px solid transparent',
|
||||
borderTopColor: color,
|
||||
borderLeftColor: color,
|
||||
borderRadius: '9999px',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`animate-spin ${className}`}
|
||||
style={style}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
);
|
||||
}
|
||||
146
apps/website/ui/MockupStack.tsx
Normal file
146
apps/website/ui/MockupStack.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
interface MockupStackProps {
|
||||
children: ReactNode;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export default function MockupStack({ children, index = 0 }: MockupStackProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(true); // Default to mobile (no animations)
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
const seed = index * 1337;
|
||||
const rotation1 = ((seed * 17) % 80 - 40) / 20;
|
||||
const rotation2 = ((seed * 23) % 80 - 40) / 20;
|
||||
|
||||
// On mobile or before mount, render without animations
|
||||
if (!isMounted || isMobile) {
|
||||
return (
|
||||
<div className="relative w-full h-full scale-60 sm:scale-70 md:scale-85 lg:scale-95 max-w-[85vw] mx-auto my-4 sm:my-0" style={{ perspective: '1200px' }}>
|
||||
<div
|
||||
className="absolute rounded-lg bg-iron-gray/80 border border-charcoal-outline"
|
||||
style={{
|
||||
rotate: `${rotation1}deg`,
|
||||
zIndex: 1,
|
||||
top: '-8px',
|
||||
left: '-8px',
|
||||
right: '-8px',
|
||||
bottom: '-8px',
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.3)',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute rounded-lg bg-iron-gray/90 border border-charcoal-outline"
|
||||
style={{
|
||||
rotate: `${rotation2}deg`,
|
||||
zIndex: 2,
|
||||
top: '-4px',
|
||||
left: '-4px',
|
||||
right: '-4px',
|
||||
bottom: '-4px',
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="relative z-10 w-full h-full rounded-lg overflow-hidden"
|
||||
style={{
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.45)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: render with animations
|
||||
return (
|
||||
<div className="relative w-full h-full scale-60 sm:scale-70 md:scale-85 lg:scale-95 max-w-[85vw] mx-auto my-4 sm:my-0" style={{ perspective: '1200px' }}>
|
||||
<motion.div
|
||||
className="absolute rounded-lg bg-iron-gray/80 border border-charcoal-outline"
|
||||
style={{
|
||||
rotate: `${rotation1}deg`,
|
||||
zIndex: 1,
|
||||
top: '-8px',
|
||||
left: '-8px',
|
||||
right: '-8px',
|
||||
bottom: '-8px',
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
initial={{ opacity: 0, scale: 0.92 }}
|
||||
animate={{ opacity: 0.5, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="absolute rounded-lg bg-iron-gray/90 border border-charcoal-outline"
|
||||
style={{
|
||||
rotate: `${rotation2}deg`,
|
||||
zIndex: 2,
|
||||
top: '-4px',
|
||||
left: '-4px',
|
||||
right: '-4px',
|
||||
bottom: '-4px',
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
|
||||
}}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 0.7, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.15 }}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="relative z-10 w-full h-full rounded-lg overflow-hidden"
|
||||
style={{
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.45)',
|
||||
}}
|
||||
whileHover={
|
||||
shouldReduceMotion
|
||||
? {}
|
||||
: {
|
||||
scale: 1.02,
|
||||
rotateY: 3,
|
||||
rotateX: -2,
|
||||
y: -12,
|
||||
transition: {
|
||||
type: 'spring',
|
||||
stiffness: 200,
|
||||
damping: 20,
|
||||
},
|
||||
}
|
||||
}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.2 }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0 pointer-events-none rounded-lg"
|
||||
whileHover={
|
||||
shouldReduceMotion
|
||||
? {}
|
||||
: {
|
||||
boxShadow: '0 0 40px rgba(25, 140, 255, 0.4)',
|
||||
transition: { duration: 0.2 },
|
||||
}
|
||||
}
|
||||
/>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
apps/website/ui/Modal.tsx
Normal file
185
apps/website/ui/Modal.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
} from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Heading } from './Heading';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface ModalProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: ReactNode;
|
||||
primaryActionLabel?: string;
|
||||
secondaryActionLabel?: string;
|
||||
onPrimaryAction?: () => void | Promise<void>;
|
||||
onSecondaryAction?: () => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
primaryActionLabel,
|
||||
secondaryActionLabel,
|
||||
onPrimaryAction,
|
||||
onSecondaryAction,
|
||||
onOpenChange,
|
||||
isOpen,
|
||||
}: ModalProps) {
|
||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const previouslyFocusedElementRef = useRef<Element | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
previouslyFocusedElementRef.current = document.activeElement;
|
||||
const focusable = getFirstFocusable(dialogRef.current);
|
||||
if (focusable) {
|
||||
focusable.focus();
|
||||
} else if (dialogRef.current) {
|
||||
dialogRef.current.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOpen && previouslyFocusedElementRef.current instanceof HTMLElement) {
|
||||
previouslyFocusedElementRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (onOpenChange) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
const focusable = getFocusableElements(dialogRef.current);
|
||||
if (focusable.length === 0) return;
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1] ?? first;
|
||||
|
||||
if (!first || !last) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
} else if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget && onOpenChange) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0, 0, 0, 0.6)', padding: '0 1rem' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
aria-describedby={description ? 'modal-description' : undefined}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<Box
|
||||
ref={dialogRef}
|
||||
style={{ width: '100%', maxWidth: '28rem', borderRadius: '1rem', backgroundColor: '#0f1115', border: '1px solid #262626', boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)', outline: 'none' }}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Box p={6} style={{ borderBottom: '1px solid rgba(38, 38, 38, 0.8)' }}>
|
||||
<Heading level={2} id="modal-title">{title}</Heading>
|
||||
{description && (
|
||||
<Text
|
||||
id="modal-description"
|
||||
size="sm"
|
||||
color="text-gray-400"
|
||||
block
|
||||
mt={2}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box p={6}>
|
||||
<Text size="sm" color="text-gray-100">{children}</Text>
|
||||
</Box>
|
||||
|
||||
{(primaryActionLabel || secondaryActionLabel) && (
|
||||
<Box p={6} style={{ borderTop: '1px solid rgba(38, 38, 38, 0.8)', display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
|
||||
{secondaryActionLabel && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSecondaryAction?.();
|
||||
onOpenChange?.(false);
|
||||
}}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{secondaryActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
{primaryActionLabel && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (onPrimaryAction) {
|
||||
await onPrimaryAction();
|
||||
}
|
||||
}}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
{primaryActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function getFocusableElements(root: HTMLElement | null): HTMLElement[] {
|
||||
if (!root) return [];
|
||||
const selectors = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
];
|
||||
const nodes = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(selectors.join(',')),
|
||||
);
|
||||
return nodes.filter((el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden'));
|
||||
}
|
||||
|
||||
function getFirstFocusable(root: HTMLElement | null): HTMLElement | null {
|
||||
const elements = getFocusableElements(root);
|
||||
return elements[0] ?? null;
|
||||
}
|
||||
49
apps/website/ui/PageHeader.tsx
Normal file
49
apps/website/ui/PageHeader.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
import { Heading } from './Heading';
|
||||
import { Surface } from './Surface';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface PageHeaderProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
iconGradient?: string;
|
||||
iconBorder?: string;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
iconGradient = 'from-iron-gray to-deep-graphite',
|
||||
iconBorder = 'border-charcoal-outline',
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<Box mb={8}>
|
||||
<Stack direction="row" align="center" justify="between" wrap gap={4}>
|
||||
<Box>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Surface variant="muted" rounded="xl" border padding={3} className={`bg-gradient-to-br ${iconGradient} ${iconBorder}`}>
|
||||
<Icon icon={icon} size={7} color="#d1d5db" />
|
||||
</Surface>
|
||||
<Box>
|
||||
<Heading level={1}>{title}</Heading>
|
||||
{description && (
|
||||
<Text color="text-gray-400" block mt={1}>{description}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
{action && <Box>{action}</Box>}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
22
apps/website/ui/PlaceholderImage.tsx
Normal file
22
apps/website/ui/PlaceholderImage.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { User } from 'lucide-react';
|
||||
import { Box } from './Box';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export interface PlaceholderImageProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PlaceholderImage({ size = 48, className = '' }: PlaceholderImageProps) {
|
||||
return (
|
||||
<Box
|
||||
className={`rounded-full bg-charcoal-outline flex items-center justify-center ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<Icon icon={User} size={6} color="#9ca3af" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
122
apps/website/ui/PresetCard.tsx
Normal file
122
apps/website/ui/PresetCard.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import type { MouseEventHandler, ReactNode } from 'react';
|
||||
import Card from './Card';
|
||||
|
||||
interface PresetCardStat {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PresetCardProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
primaryTag?: string;
|
||||
description?: string;
|
||||
stats?: PresetCardStat[];
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
onSelect?: () => void;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function PresetCard({
|
||||
title,
|
||||
subtitle,
|
||||
primaryTag,
|
||||
description,
|
||||
stats,
|
||||
selected,
|
||||
disabled,
|
||||
onSelect,
|
||||
className = '',
|
||||
children,
|
||||
}: PresetCardProps) {
|
||||
const isInteractive = typeof onSelect === 'function' && !disabled;
|
||||
|
||||
const handleClick: MouseEventHandler<HTMLButtonElement | HTMLDivElement> = (event) => {
|
||||
if (!isInteractive) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
onSelect?.();
|
||||
};
|
||||
|
||||
const baseBorder = selected ? 'border-primary-blue' : 'border-charcoal-outline';
|
||||
const baseBg = selected ? 'bg-primary-blue/10' : 'bg-iron-gray';
|
||||
const baseRing = selected ? 'ring-2 ring-primary-blue/40' : '';
|
||||
const disabledClasses = disabled ? 'opacity-60 cursor-not-allowed' : '';
|
||||
const hoverClasses = isInteractive && !disabled ? 'hover:bg-iron-gray/80 hover:scale-[1.01]' : '';
|
||||
|
||||
const content = (
|
||||
<div className="flex h-full flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{title}</div>
|
||||
{subtitle && (
|
||||
<div className="mt-0.5 text-xs text-gray-400">{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{primaryTag && (
|
||||
<span className="inline-flex rounded-full bg-primary-blue/15 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary-blue">
|
||||
{primaryTag}
|
||||
</span>
|
||||
)}
|
||||
{selected && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary-blue/10 px-2 py-0.5 text-[10px] font-medium text-primary-blue">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary-blue" />
|
||||
Selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<p className="text-xs text-gray-300">{description}</p>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{stats && stats.length > 0 && (
|
||||
<div className="mt-1 border-t border-charcoal-outline/70 pt-2">
|
||||
<dl className="grid grid-cols-1 gap-2 text-[11px] text-gray-400 sm:grid-cols-3">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="space-y-0.5">
|
||||
<dt className="font-medium text-gray-500">{stat.label}</dt>
|
||||
<dd className="text-xs text-gray-200">{stat.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const commonClasses = `${baseBorder} ${baseBg} ${baseRing} ${hoverClasses} ${disabledClasses} ${className}`;
|
||||
|
||||
if (isInteractive) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick as MouseEventHandler<HTMLButtonElement>}
|
||||
disabled={disabled}
|
||||
className={`group block w-full rounded-lg text-left text-sm shadow-card outline-none transition-all duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-blue ${commonClasses}`}
|
||||
>
|
||||
<div className="p-4">
|
||||
{content}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={commonClasses}
|
||||
onClick={handleClick as MouseEventHandler<HTMLDivElement>}
|
||||
>
|
||||
{content}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface QuickActionLinkProps {
|
||||
href: string;
|
||||
|
||||
271
apps/website/ui/RangeField.tsx
Normal file
271
apps/website/ui/RangeField.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useRef, useState, useEffect } from 'react';
|
||||
|
||||
interface RangeFieldProps {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
onChange: (value: number) => void;
|
||||
helperText?: string;
|
||||
error?: string | undefined;
|
||||
disabled?: boolean;
|
||||
unitLabel?: string;
|
||||
rangeHint?: string;
|
||||
/** Show large value display above slider */
|
||||
showLargeValue?: boolean;
|
||||
/** Compact mode - single line */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export default function RangeField({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
onChange,
|
||||
helperText,
|
||||
error,
|
||||
disabled,
|
||||
unitLabel = 'min',
|
||||
rangeHint,
|
||||
showLargeValue = false,
|
||||
compact = false,
|
||||
}: RangeFieldProps) {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Sync local value with prop when not dragging
|
||||
useEffect(() => {
|
||||
if (!isDragging) {
|
||||
setLocalValue(value);
|
||||
}
|
||||
}, [value, isDragging]);
|
||||
|
||||
const clampedValue = Number.isFinite(localValue)
|
||||
? Math.min(Math.max(localValue, min), max)
|
||||
: min;
|
||||
|
||||
const rangePercent = ((clampedValue - min) / Math.max(max - min, 1)) * 100;
|
||||
|
||||
const effectiveRangeHint =
|
||||
rangeHint ?? (min === 0 ? `Up to ${max} ${unitLabel}` : `${min}–${max} ${unitLabel}`);
|
||||
|
||||
const calculateValueFromPosition = useCallback(
|
||||
(clientX: number) => {
|
||||
if (!sliderRef.current) return clampedValue;
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
const percent = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1);
|
||||
const rawValue = min + percent * (max - min);
|
||||
const steppedValue = Math.round(rawValue / step) * step;
|
||||
return Math.min(Math.max(steppedValue, min), max);
|
||||
},
|
||||
[min, max, step, clampedValue]
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
const newValue = calculateValueFromPosition(e.clientX);
|
||||
setLocalValue(newValue);
|
||||
onChange(newValue);
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[disabled, calculateValueFromPosition, onChange]
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isDragging || disabled) return;
|
||||
const newValue = calculateValueFromPosition(e.clientX);
|
||||
setLocalValue(newValue);
|
||||
onChange(newValue);
|
||||
},
|
||||
[isDragging, disabled, calculateValueFromPosition, onChange]
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === '') {
|
||||
setLocalValue(min);
|
||||
return;
|
||||
}
|
||||
const parsed = parseInt(raw, 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
const clamped = Math.min(Math.max(parsed, min), max);
|
||||
setLocalValue(clamped);
|
||||
onChange(clamped);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
// Ensure value is synced on blur
|
||||
onChange(clampedValue);
|
||||
};
|
||||
|
||||
// Quick preset buttons for common values
|
||||
const quickPresets = [
|
||||
Math.round(min + (max - min) * 0.25),
|
||||
Math.round(min + (max - min) * 0.5),
|
||||
Math.round(min + (max - min) * 0.75),
|
||||
].filter((v, i, arr) => arr.indexOf(v) === i && v !== clampedValue);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="text-xs font-medium text-gray-400 shrink-0">{label}</label>
|
||||
<div className="flex items-center gap-2 flex-1 max-w-[200px]">
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className={`relative flex-1 h-6 cursor-pointer touch-none ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
{/* Track background */}
|
||||
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-1.5 rounded-full bg-charcoal-outline" />
|
||||
{/* Track fill */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 left-0 h-1.5 rounded-full bg-primary-blue transition-all duration-75"
|
||||
style={{ width: `${rangePercent}%` }}
|
||||
/>
|
||||
{/* Thumb */}
|
||||
<div
|
||||
className={`
|
||||
absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-4 h-4 rounded-full
|
||||
bg-white border-2 border-primary-blue shadow-md
|
||||
transition-transform duration-75
|
||||
${isDragging ? 'scale-125 shadow-[0_0_12px_rgba(25,140,255,0.5)]' : ''}
|
||||
`}
|
||||
style={{ left: `${rangePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-sm font-semibold text-white w-8 text-right">{clampedValue}</span>
|
||||
<span className="text-[10px] text-gray-500">{unitLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-[10px] text-warning-amber">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<label className="block text-sm font-medium text-gray-300">{label}</label>
|
||||
<span className="text-[10px] text-gray-500">{effectiveRangeHint}</span>
|
||||
</div>
|
||||
|
||||
{showLargeValue && (
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-3xl font-bold text-white tabular-nums">{clampedValue}</span>
|
||||
<span className="text-sm text-gray-400">{unitLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom slider */}
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className={`relative h-8 cursor-pointer touch-none select-none ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
{/* Track background */}
|
||||
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-2 rounded-full bg-charcoal-outline/80" />
|
||||
|
||||
{/* Track fill with gradient */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 left-0 h-2 rounded-full bg-gradient-to-r from-primary-blue to-neon-aqua transition-all duration-75"
|
||||
style={{ width: `${rangePercent}%` }}
|
||||
/>
|
||||
|
||||
{/* Tick marks */}
|
||||
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 flex justify-between px-1">
|
||||
{[0, 25, 50, 75, 100].map((tick) => (
|
||||
<div
|
||||
key={tick}
|
||||
className={`w-0.5 h-1 rounded-full transition-colors ${
|
||||
rangePercent >= tick ? 'bg-white/40' : 'bg-charcoal-outline'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Thumb */}
|
||||
<div
|
||||
className={`
|
||||
absolute top-1/2 -translate-y-1/2 -translate-x-1/2
|
||||
w-5 h-5 rounded-full bg-white border-2 border-primary-blue
|
||||
shadow-[0_2px_8px_rgba(0,0,0,0.3)]
|
||||
transition-all duration-75
|
||||
${isDragging ? 'scale-125 shadow-[0_0_16px_rgba(25,140,255,0.6)]' : 'hover:scale-110'}
|
||||
`}
|
||||
style={{ left: `${rangePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Value input and quick presets */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={clampedValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputBlur}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
w-16 px-2 py-1.5 text-sm font-medium text-center rounded-lg
|
||||
bg-iron-gray border border-charcoal-outline text-white
|
||||
focus:border-primary-blue focus:ring-1 focus:ring-primary-blue focus:outline-none
|
||||
transition-colors
|
||||
${error ? 'border-warning-amber' : ''}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
/>
|
||||
<span className="text-xs text-gray-400">{unitLabel}</span>
|
||||
</div>
|
||||
|
||||
{quickPresets.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
{quickPresets.slice(0, 3).map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLocalValue(preset);
|
||||
onChange(preset);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="px-2 py-1 text-[10px] rounded bg-charcoal-outline/50 text-gray-400 hover:bg-charcoal-outline hover:text-white transition-colors"
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{helperText && <p className="text-xs text-gray-500">{helperText}</p>}
|
||||
{error && <p className="text-xs text-warning-amber">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface SectionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
variant?: 'default' | 'card' | 'highlight';
|
||||
variant?: 'default' | 'card' | 'highlight' | 'dark' | 'light';
|
||||
id?: string;
|
||||
py?: number;
|
||||
}
|
||||
|
||||
export function Section({
|
||||
@@ -13,31 +20,34 @@ export function Section({
|
||||
className = '',
|
||||
title,
|
||||
description,
|
||||
variant = 'default'
|
||||
variant = 'default',
|
||||
id,
|
||||
py = 16
|
||||
}: SectionProps) {
|
||||
const baseClasses = 'space-y-4';
|
||||
|
||||
const variantClasses = {
|
||||
default: '',
|
||||
card: 'bg-iron-gray rounded-lg p-6 border border-charcoal-outline',
|
||||
highlight: 'bg-gradient-to-r from-blue-900/20 to-blue-700/10 rounded-lg p-6 border border-blue-500/30'
|
||||
highlight: 'bg-gradient-to-r from-blue-900/20 to-blue-700/10 rounded-lg p-6 border border-blue-500/30',
|
||||
dark: 'bg-iron-gray',
|
||||
light: 'bg-charcoal-outline'
|
||||
};
|
||||
|
||||
const classes = [
|
||||
baseClasses,
|
||||
variantClasses[variant],
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<section className={classes}>
|
||||
{title && (
|
||||
<h2 className="text-xl font-semibold text-white">{title}</h2>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-sm text-gray-400">{description}</p>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
<Box as="section" id={id} className={classes} py={py as 0} px={4}>
|
||||
<Box className="mx-auto max-w-7xl">
|
||||
{(title || description) && (
|
||||
<Box mb={8}>
|
||||
{title && <Heading level={2}>{title}</Heading>}
|
||||
{description && <Text color="text-gray-400" block mt={2}>{description}</Text>}
|
||||
</Box>
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
47
apps/website/ui/SectionHeader.tsx
Normal file
47
apps/website/ui/SectionHeader.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
import { Heading } from './Heading';
|
||||
import { Surface } from './Surface';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface SectionHeaderProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
color = '#3b82f6'
|
||||
}: SectionHeaderProps) {
|
||||
return (
|
||||
<Box p={5} style={{ borderBottom: '1px solid #262626', background: 'linear-gradient(to right, rgba(38, 38, 38, 0.3), transparent)' }}>
|
||||
<Stack direction="row" align="center" justify="between" wrap gap={4}>
|
||||
<Box>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)' }}>
|
||||
<Icon icon={icon} size={5} color={color} />
|
||||
</Surface>
|
||||
<Box>
|
||||
<Heading level={2}>{title}</Heading>
|
||||
{description && (
|
||||
<Text size="sm" color="text-gray-500" block mt={1}>{description}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
{action && <Box>{action}</Box>}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
72
apps/website/ui/SegmentedControl.tsx
Normal file
72
apps/website/ui/SegmentedControl.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface SegmentedControlOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SegmentedControlProps {
|
||||
options: SegmentedControlOption[];
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SegmentedControl({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: SegmentedControlProps) {
|
||||
const handleSelect = (optionValue: string, optionDisabled?: boolean) => {
|
||||
if (!onChange || optionDisabled) return;
|
||||
if (optionValue === value) return;
|
||||
onChange(optionValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box style={{ display: 'inline-flex', width: '100%', flexWrap: 'wrap', gap: '0.5rem', borderRadius: '9999px', backgroundColor: 'rgba(38, 38, 38, 0.6)', padding: '0.25rem' }}>
|
||||
{options.map((option) => {
|
||||
const isSelected = option.value === value;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={option.value}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => handleSelect(option.value, option.disabled)}
|
||||
aria-pressed={isSelected}
|
||||
disabled={option.disabled}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: '140px',
|
||||
padding: '0.375rem 0.75rem',
|
||||
borderRadius: '9999px',
|
||||
transition: 'all 0.2s',
|
||||
textAlign: 'left',
|
||||
backgroundColor: isSelected ? '#3b82f6' : 'transparent',
|
||||
color: isSelected ? 'white' : '#d1d5db',
|
||||
opacity: option.disabled ? 0.5 : 1,
|
||||
cursor: option.disabled ? 'not-allowed' : 'pointer',
|
||||
border: 'none'
|
||||
}}
|
||||
>
|
||||
<Stack gap={0.5}>
|
||||
<Text size="xs" weight="medium" color="inherit">{option.label}</Text>
|
||||
{option.description && (
|
||||
<Text size="xs" color={isSelected ? 'text-white' : 'text-gray-400'} style={{ fontSize: '10px', opacity: isSelected ? 0.8 : 1 }}>
|
||||
{option.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -5,13 +5,14 @@ interface SelectOption {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
id?: string;
|
||||
'aria-label'?: string;
|
||||
value?: string;
|
||||
onChange?: (e: ChangeEvent<HTMLSelectElement>) => void;
|
||||
options: SelectOption[];
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function Select({
|
||||
@@ -21,6 +22,8 @@ export function Select({
|
||||
onChange,
|
||||
options,
|
||||
className = '',
|
||||
style,
|
||||
...props
|
||||
}: SelectProps) {
|
||||
const defaultClasses = 'w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:border-primary-blue transition-colors';
|
||||
const classes = className ? `${defaultClasses} ${className}` : defaultClasses;
|
||||
@@ -32,6 +35,8 @@ export function Select({
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={classes}
|
||||
style={style}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
@@ -40,4 +45,4 @@ export function Select({
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
26
apps/website/ui/Skeleton.tsx
Normal file
26
apps/website/ui/Skeleton.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SkeletonProps {
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
circle?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Skeleton({ width, height, circle, className = '' }: SkeletonProps) {
|
||||
const style: React.CSSProperties = {
|
||||
width: width,
|
||||
height: height,
|
||||
borderRadius: circle ? '9999px' : '0.375rem',
|
||||
backgroundColor: 'rgba(38, 38, 38, 0.4)',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`animate-pulse ${className}`}
|
||||
style={style}
|
||||
role="status"
|
||||
aria-label="Loading..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* SponsorLogo
|
||||
*
|
||||
* Pure UI component for displaying sponsor logos.
|
||||
* Renders an optimized image with fallback on error.
|
||||
*/
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
export interface SponsorLogoProps {
|
||||
sponsorId: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SponsorLogo({ sponsorId, alt, className = '' }: SponsorLogoProps) {
|
||||
return (
|
||||
<Image
|
||||
src={`/media/sponsors/${sponsorId}/logo`}
|
||||
alt={alt}
|
||||
width={100}
|
||||
height={100}
|
||||
className={`object-contain ${className}`}
|
||||
onError={(e) => {
|
||||
// Fallback to default logo
|
||||
(e.target as HTMLImageElement).src = '/default-sponsor-logo.png';
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
95
apps/website/ui/Stack.tsx
Normal file
95
apps/website/ui/Stack.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { ReactNode, HTMLAttributes } from 'react';
|
||||
import { Box } from './Box';
|
||||
|
||||
type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
|
||||
|
||||
interface StackProps extends HTMLAttributes<HTMLElement> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
direction?: 'row' | 'col';
|
||||
gap?: number;
|
||||
align?: 'start' | 'center' | 'end' | 'stretch' | 'baseline';
|
||||
justify?: 'start' | 'center' | 'end' | 'between' | 'around';
|
||||
wrap?: boolean;
|
||||
center?: boolean;
|
||||
m?: Spacing;
|
||||
mt?: Spacing;
|
||||
mb?: Spacing;
|
||||
ml?: Spacing;
|
||||
mr?: Spacing;
|
||||
p?: Spacing;
|
||||
pt?: Spacing;
|
||||
pb?: Spacing;
|
||||
pl?: Spacing;
|
||||
pr?: Spacing;
|
||||
px?: Spacing;
|
||||
py?: Spacing;
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
|
||||
}
|
||||
|
||||
export function Stack({
|
||||
children,
|
||||
className = '',
|
||||
direction = 'col',
|
||||
gap = 4,
|
||||
align = 'stretch',
|
||||
justify = 'start',
|
||||
wrap = false,
|
||||
center = false,
|
||||
m, mt, mb, ml, mr,
|
||||
p, pt, pb, pl, pr, px, py,
|
||||
rounded,
|
||||
...props
|
||||
}: StackProps) {
|
||||
const gapClasses: Record<number, string> = {
|
||||
0: 'gap-0',
|
||||
1: 'gap-1',
|
||||
2: 'gap-2',
|
||||
3: 'gap-3',
|
||||
4: 'gap-4',
|
||||
6: 'gap-6',
|
||||
8: 'gap-8',
|
||||
12: 'gap-12'
|
||||
};
|
||||
|
||||
const spacingMap: Record<number, string> = {
|
||||
0: '0', 0.5: '0.5', 1: '1', 1.5: '1.5', 2: '2', 2.5: '2.5', 3: '3', 3.5: '3.5', 4: '4',
|
||||
5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: '11', 12: '12', 14: '14',
|
||||
16: '16', 20: '20', 24: '24', 28: '28', 32: '32', 36: '36', 40: '40', 44: '44',
|
||||
48: '48', 52: '52', 56: '56', 60: '60', 64: '64', 72: '72', 80: '80', 96: '96'
|
||||
};
|
||||
|
||||
const roundedClasses = {
|
||||
none: 'rounded-none',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
xl: 'rounded-xl',
|
||||
'2xl': 'rounded-2xl',
|
||||
full: 'rounded-full'
|
||||
};
|
||||
|
||||
const classes = [
|
||||
'flex',
|
||||
direction === 'col' ? 'flex-col' : 'flex-row',
|
||||
gapClasses[gap] || 'gap-4',
|
||||
center ? 'items-center justify-center' : `items-${align} justify-${justify}`,
|
||||
wrap ? 'flex-wrap' : '',
|
||||
m !== undefined ? `m-${spacingMap[m]}` : '',
|
||||
mt !== undefined ? `mt-${spacingMap[mt]}` : '',
|
||||
mb !== undefined ? `mb-${spacingMap[mb]}` : '',
|
||||
ml !== undefined ? `ml-${spacingMap[ml]}` : '',
|
||||
mr !== undefined ? `mr-${spacingMap[mr]}` : '',
|
||||
p !== undefined ? `p-${spacingMap[p]}` : '',
|
||||
pt !== undefined ? `pt-${spacingMap[pt]}` : '',
|
||||
pb !== undefined ? `pb-${spacingMap[pb]}` : '',
|
||||
pl !== undefined ? `pl-${spacingMap[pl]}` : '',
|
||||
pr !== undefined ? `pr-${spacingMap[pr]}` : '',
|
||||
px !== undefined ? `px-${spacingMap[px]}` : '',
|
||||
py !== undefined ? `py-${spacingMap[py]}` : '',
|
||||
rounded ? roundedClasses[rounded] : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return <Box className={classes} {...props}>{children}</Box>;
|
||||
}
|
||||
@@ -1,21 +1,30 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
import { Card } from './Card';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon?: ReactNode;
|
||||
subValue?: string;
|
||||
icon?: LucideIcon;
|
||||
variant?: 'blue' | 'purple' | 'green' | 'orange';
|
||||
className?: string;
|
||||
trend?: {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
subValue,
|
||||
icon,
|
||||
variant = 'blue',
|
||||
className = ''
|
||||
className = '',
|
||||
trend,
|
||||
}: StatCardProps) {
|
||||
const variantClasses = {
|
||||
blue: 'bg-gradient-to-br from-blue-900/20 to-blue-700/10 border-blue-500/30',
|
||||
@@ -25,28 +34,38 @@ export function StatCard({
|
||||
};
|
||||
|
||||
const iconColorClasses = {
|
||||
blue: 'text-blue-400',
|
||||
purple: 'text-purple-400',
|
||||
green: 'text-green-400',
|
||||
orange: 'text-orange-400'
|
||||
blue: '#60a5fa',
|
||||
purple: '#a78bfa',
|
||||
green: '#34d399',
|
||||
orange: '#fb923c'
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`${variantClasses[variant]} ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<Text size="sm" color="text-gray-400" className="mb-1">
|
||||
<Text size="sm" color="text-gray-400" className="mb-1" block>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="3xl" weight="bold" color="text-white">
|
||||
<Text size="3xl" weight="bold" color="text-white" block>
|
||||
{value}
|
||||
</Text>
|
||||
{subValue && (
|
||||
<Text size="xs" color="text-gray-500" className="mt-1" block>
|
||||
{subValue}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{icon && (
|
||||
<Icon icon={icon} size={8} color={iconColorClasses[variant]} />
|
||||
)}
|
||||
{trend && (
|
||||
<Text size="sm" color={trend.isPositive ? 'text-performance-green' : 'text-error-red'}>
|
||||
{trend.isPositive ? '↑' : '↓'}{Math.abs(trend.value)}%
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div className={iconColorClasses[variant]}>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,33 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Stack } from './Stack';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'success' | 'warning' | 'error' | 'info';
|
||||
variant?: 'success' | 'warning' | 'error' | 'info' | 'neutral' | 'pending';
|
||||
className?: string;
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
export function StatusBadge({
|
||||
children,
|
||||
variant = 'success',
|
||||
className = ''
|
||||
className = '',
|
||||
icon,
|
||||
}: StatusBadgeProps) {
|
||||
const variantClasses = {
|
||||
success: 'bg-performance-green/20 text-performance-green',
|
||||
warning: 'bg-warning-amber/20 text-warning-amber',
|
||||
error: 'bg-red-600/20 text-red-400',
|
||||
info: 'bg-blue-500/20 text-blue-400'
|
||||
success: 'bg-performance-green/20 text-performance-green border-performance-green/30',
|
||||
warning: 'bg-warning-amber/20 text-warning-amber border-warning-amber/30',
|
||||
error: 'bg-red-600/20 text-red-400 border-red-600/30',
|
||||
info: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
neutral: 'bg-iron-gray text-gray-400 border-charcoal-outline',
|
||||
pending: 'bg-warning-amber/20 text-warning-amber border-warning-amber/30',
|
||||
};
|
||||
|
||||
const classes = [
|
||||
'px-2 py-1 text-xs rounded-full',
|
||||
'px-2 py-0.5 text-xs rounded-full border font-medium inline-flex items-center',
|
||||
variantClasses[variant],
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<Text size="xs" className={classes}>
|
||||
const content = icon ? (
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Icon icon={icon} size={3} />
|
||||
{children}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : children;
|
||||
|
||||
return (
|
||||
<span className={classes}>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
70
apps/website/ui/Surface.tsx
Normal file
70
apps/website/ui/Surface.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { ReactNode, HTMLAttributes } from 'react';
|
||||
import { Box } from './Box';
|
||||
|
||||
interface SurfaceProps extends HTMLAttributes<HTMLElement> {
|
||||
children: ReactNode;
|
||||
variant?: 'default' | 'muted' | 'dark' | 'glass' | 'gradient-blue' | 'gradient-gold' | 'gradient-purple';
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
|
||||
border?: boolean;
|
||||
padding?: number;
|
||||
className?: string;
|
||||
display?: 'block' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'none';
|
||||
}
|
||||
|
||||
export function Surface({
|
||||
children,
|
||||
variant = 'default',
|
||||
rounded = 'lg',
|
||||
border = false,
|
||||
padding = 0,
|
||||
className = '',
|
||||
display,
|
||||
...props
|
||||
}: SurfaceProps) {
|
||||
const variantClasses = {
|
||||
default: 'bg-iron-gray',
|
||||
muted: 'bg-iron-gray/50',
|
||||
dark: 'bg-deep-graphite',
|
||||
glass: 'bg-deep-graphite/60 backdrop-blur-md',
|
||||
'gradient-blue': 'bg-gradient-to-br from-primary-blue/20 via-iron-gray/80 to-deep-graphite',
|
||||
'gradient-gold': 'bg-gradient-to-br from-yellow-600/20 via-iron-gray/80 to-deep-graphite',
|
||||
'gradient-purple': 'bg-gradient-to-br from-purple-600/20 via-iron-gray/80 to-deep-graphite'
|
||||
};
|
||||
|
||||
const roundedClasses = {
|
||||
none: 'rounded-none',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
xl: 'rounded-xl',
|
||||
'2xl': 'rounded-2xl',
|
||||
full: 'rounded-full'
|
||||
};
|
||||
|
||||
const paddingClasses: Record<number, string> = {
|
||||
0: 'p-0',
|
||||
1: 'p-1',
|
||||
2: 'p-2',
|
||||
3: 'p-3',
|
||||
4: 'p-4',
|
||||
6: 'p-6',
|
||||
8: 'p-8',
|
||||
10: 'p-10',
|
||||
12: 'p-12'
|
||||
};
|
||||
|
||||
const classes = [
|
||||
variantClasses[variant],
|
||||
roundedClasses[rounded],
|
||||
border ? 'border border-charcoal-outline' : '',
|
||||
paddingClasses[padding] || 'p-0',
|
||||
display ? display : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<Box className={classes} {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
9
apps/website/ui/TabContent.tsx
Normal file
9
apps/website/ui/TabContent.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
export function TabContent({ children, className = '' }: { children: React.ReactNode, className?: string }) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
apps/website/ui/TabNavigation.tsx
Normal file
39
apps/website/ui/TabNavigation.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
interface TabNavigationProps {
|
||||
tabs: Tab[];
|
||||
activeTab: string;
|
||||
onTabChange: (tabId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TabNavigation({ tabs, activeTab, onTabChange, className = '' }: TabNavigationProps) {
|
||||
return (
|
||||
<div className={`flex items-center gap-1 p-1.5 rounded-xl bg-iron-gray/50 border border-charcoal-outline w-fit relative z-10 ${className}`}>
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all cursor-pointer select-none ${
|
||||
activeTab === tab.id
|
||||
? 'bg-primary-blue text-white shadow-lg shadow-primary-blue/25'
|
||||
: 'text-gray-400 hover:text-white hover:bg-iron-gray/80'
|
||||
}`}
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4" />}
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +1,88 @@
|
||||
import { ReactNode } from 'react';
|
||||
import React, { ReactNode, HTMLAttributes } from 'react';
|
||||
|
||||
interface TableProps {
|
||||
interface TableProps extends HTMLAttributes<HTMLTableElement> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Table({ children, className = '' }: TableProps) {
|
||||
export function Table({ children, className = '', ...props }: TableProps) {
|
||||
return (
|
||||
<div className={`overflow-x-auto ${className}`}>
|
||||
<table className="w-full">
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className={`w-full ${className}`} {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableHeadProps {
|
||||
interface TableHeadProps extends HTMLAttributes<HTMLTableSectionElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function TableHead({ children }: TableHeadProps) {
|
||||
export function TableHead({ children, ...props }: TableHeadProps) {
|
||||
return (
|
||||
<thead>
|
||||
<thead {...props}>
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableBodyProps {
|
||||
interface TableBodyProps extends HTMLAttributes<HTMLTableSectionElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function TableBody({ children }: TableBodyProps) {
|
||||
export function TableBody({ children, ...props }: TableBodyProps) {
|
||||
return (
|
||||
<tbody>
|
||||
<tbody {...props}>
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableRowProps {
|
||||
interface TableRowProps extends HTMLAttributes<HTMLTableRowElement> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TableRow({ children, className = '' }: TableRowProps) {
|
||||
export function TableRow({ children, className = '', ...props }: TableRowProps) {
|
||||
const baseClasses = 'border-b border-charcoal-outline/50 hover:bg-iron-gray/30 transition-colors';
|
||||
const classes = className ? `${baseClasses} ${className}` : baseClasses;
|
||||
|
||||
return (
|
||||
<tr className={classes}>
|
||||
<tr className={classes} {...props}>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableHeaderProps {
|
||||
interface TableHeaderProps extends HTMLAttributes<HTMLTableCellElement> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TableHeader({ children, className = '' }: TableHeaderProps) {
|
||||
export function TableHeader({ children, className = '', ...props }: TableHeaderProps) {
|
||||
const baseClasses = 'text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase';
|
||||
const classes = className ? `${baseClasses} ${className}` : baseClasses;
|
||||
|
||||
return (
|
||||
<th className={classes}>
|
||||
<th className={classes} {...props}>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableCellProps {
|
||||
interface TableCellProps extends HTMLAttributes<HTMLTableCellElement> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TableCell({ children, className = '' }: TableCellProps) {
|
||||
export function TableCell({ children, className = '', ...props }: TableCellProps) {
|
||||
const baseClasses = 'py-3 px-4';
|
||||
const classes = className ? `${baseClasses} ${className}` : baseClasses;
|
||||
|
||||
return (
|
||||
<td className={classes}>
|
||||
<td className={classes} {...props}>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import React, { ReactNode, HTMLAttributes } from 'react';
|
||||
|
||||
interface TextProps {
|
||||
type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
|
||||
|
||||
interface TextProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
size?: 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
|
||||
@@ -9,6 +11,12 @@ interface TextProps {
|
||||
font?: 'mono' | 'sans';
|
||||
align?: 'left' | 'center' | 'right';
|
||||
truncate?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
block?: boolean;
|
||||
ml?: Spacing;
|
||||
mr?: Spacing;
|
||||
mt?: Spacing;
|
||||
mb?: Spacing;
|
||||
}
|
||||
|
||||
export function Text({
|
||||
@@ -19,7 +27,11 @@ export function Text({
|
||||
color = '',
|
||||
font = 'sans',
|
||||
align = 'left',
|
||||
truncate = false
|
||||
truncate = false,
|
||||
style,
|
||||
block = false,
|
||||
ml, mr, mt, mb,
|
||||
...props
|
||||
}: TextProps) {
|
||||
const sizeClasses = {
|
||||
xs: 'text-xs',
|
||||
@@ -49,16 +61,28 @@ export function Text({
|
||||
center: 'text-center',
|
||||
right: 'text-right'
|
||||
};
|
||||
|
||||
const spacingMap: Record<number, string> = {
|
||||
0: '0', 0.5: '0.5', 1: '1', 1.5: '1.5', 2: '2', 2.5: '2.5', 3: '3', 3.5: '3.5', 4: '4',
|
||||
5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: '11', 12: '12', 14: '14',
|
||||
16: '16', 20: '20', 24: '24', 28: '28', 32: '32', 36: '36', 40: '40', 44: '44',
|
||||
48: '48', 52: '52', 56: '56', 60: '60', 64: '64', 72: '72', 80: '80', 96: '96'
|
||||
};
|
||||
|
||||
const classes = [
|
||||
block ? 'block' : 'inline',
|
||||
sizeClasses[size],
|
||||
weightClasses[weight],
|
||||
fontClasses[font],
|
||||
alignClasses[align],
|
||||
color,
|
||||
truncate ? 'truncate' : '',
|
||||
ml !== undefined ? `ml-${spacingMap[ml]}` : '',
|
||||
mr !== undefined ? `mr-${spacingMap[mr]}` : '',
|
||||
mt !== undefined ? `mt-${spacingMap[mt]}` : '',
|
||||
mb !== undefined ? `mb-${spacingMap[mb]}` : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return <span className={classes}>{children}</span>;
|
||||
}
|
||||
return <span className={classes} style={style} {...props}>{children}</span>;
|
||||
}
|
||||
|
||||
74
apps/website/ui/Toggle.tsx
Normal file
74
apps/website/ui/Toggle.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface ToggleProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
description,
|
||||
disabled = false,
|
||||
}: ToggleProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<label className={`flex items-start justify-between cursor-pointer py-3 border-b border-charcoal-outline/50 last:border-b-0 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}>
|
||||
<Box style={{ flex: 1, paddingRight: '1rem' }}>
|
||||
<Text weight="medium" color="text-gray-200" block>{label}</Text>
|
||||
{description && (
|
||||
<Text size="sm" color="text-gray-500" block mt={1}>{description}</Text>
|
||||
)}
|
||||
</Box>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
disabled={disabled}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors duration-200 flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-primary-blue/50 ${
|
||||
checked
|
||||
? 'bg-primary-blue'
|
||||
: 'bg-iron-gray'
|
||||
} ${disabled ? 'cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{/* Glow effect when active */}
|
||||
{checked && (
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full bg-primary-blue"
|
||||
initial={{ boxShadow: '0 0 0px rgba(25, 140, 255, 0)' }}
|
||||
animate={{ boxShadow: '0 0 12px rgba(25, 140, 255, 0.4)' }}
|
||||
transition={{ duration: shouldReduceMotion ? 0 : 0.2 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Knob */}
|
||||
<motion.span
|
||||
className="absolute top-0.5 w-5 h-5 bg-white rounded-full shadow-md"
|
||||
initial={false}
|
||||
animate={{
|
||||
x: checked ? 24 : 2,
|
||||
scale: 1,
|
||||
}}
|
||||
whileTap={{ scale: disabled ? 1 : 0.9 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 500,
|
||||
damping: 30,
|
||||
duration: shouldReduceMotion ? 0 : undefined,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export function OnboardingCardAccent() {
|
||||
return (
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-bl from-primary-blue/10 to-transparent rounded-bl-full" />
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
interface OnboardingContainerProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function OnboardingContainer({ children }: OnboardingContainerProps) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-10">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
interface OnboardingErrorProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function OnboardingError({ message }: OnboardingErrorProps) {
|
||||
return (
|
||||
<div className="mt-6 flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/30">
|
||||
<span className="text-red-400 flex-shrink-0 mt-0.5">⚠</span>
|
||||
<p className="text-sm text-red-400">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
interface OnboardingFormProps {
|
||||
children: React.ReactNode;
|
||||
onSubmit: (e: React.FormEvent) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function OnboardingForm({ children, onSubmit }: OnboardingFormProps) {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="relative">
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
interface OnboardingHeaderProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
export function OnboardingHeader({ title, subtitle, emoji }: OnboardingHeaderProps) {
|
||||
return (
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30 mx-auto mb-4">
|
||||
<span className="text-2xl">{emoji}</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold mb-2">{title}</h1>
|
||||
<p className="text-gray-400">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export function OnboardingHelpText() {
|
||||
return (
|
||||
<p className="text-center text-xs text-gray-500 mt-6">
|
||||
Your avatar will be AI-generated based on your photo and chosen suit color
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import Button from '@/components/ui/Button';
|
||||
|
||||
interface OnboardingNavigationProps {
|
||||
onBack: () => void;
|
||||
onNext?: () => void;
|
||||
isLastStep: boolean;
|
||||
canSubmit: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function OnboardingNavigation({ onBack, onNext, isLastStep, canSubmit, loading }: OnboardingNavigationProps) {
|
||||
return (
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span>←</span>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
{!isLastStep ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={onNext}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Continue
|
||||
<span>→</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading || !canSubmit}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="animate-spin">⟳</span>
|
||||
Creating Profile...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>✓</span>
|
||||
Complete Setup
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { User, Clock, ChevronRight } from 'lucide-react';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import CountrySelect from '@/components/ui/CountrySelect';
|
||||
|
||||
export interface PersonalInfo {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
displayName: string;
|
||||
country: string;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
interface PersonalInfoStepProps {
|
||||
personalInfo: PersonalInfo;
|
||||
setPersonalInfo: (info: PersonalInfo) => void;
|
||||
errors: FormErrors;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const TIMEZONES = [
|
||||
{ value: 'America/New_York', label: 'Eastern Time (ET)' },
|
||||
{ value: 'America/Chicago', label: 'Central Time (CT)' },
|
||||
{ value: 'America/Denver', label: 'Mountain Time (MT)' },
|
||||
{ value: 'America/Los_Angeles', label: 'Pacific Time (PT)' },
|
||||
{ value: 'Europe/London', label: 'Greenwich Mean Time (GMT)' },
|
||||
{ value: 'Europe/Berlin', label: 'Central European Time (CET)' },
|
||||
{ value: 'Europe/Paris', label: 'Central European Time (CET)' },
|
||||
{ value: 'Australia/Sydney', label: 'Australian Eastern Time (AET)' },
|
||||
{ value: 'Asia/Tokyo', label: 'Japan Standard Time (JST)' },
|
||||
{ value: 'America/Sao_Paulo', label: 'Brasília Time (BRT)' },
|
||||
];
|
||||
|
||||
export function PersonalInfoStep({ personalInfo, setPersonalInfo, errors, loading }: PersonalInfoStepProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Heading level={2} className="text-xl mb-1 flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-primary-blue" />
|
||||
Personal Information
|
||||
</Heading>
|
||||
<p className="text-sm text-gray-400">
|
||||
Tell us a bit about yourself
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
First Name *
|
||||
</label>
|
||||
<Input
|
||||
id="firstName"
|
||||
type="text"
|
||||
value={personalInfo.firstName}
|
||||
onChange={(e) =>
|
||||
setPersonalInfo({ ...personalInfo, firstName: e.target.value })
|
||||
}
|
||||
error={!!errors.firstName}
|
||||
errorMessage={errors.firstName}
|
||||
placeholder="John"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Last Name *
|
||||
</label>
|
||||
<Input
|
||||
id="lastName"
|
||||
type="text"
|
||||
value={personalInfo.lastName}
|
||||
onChange={(e) =>
|
||||
setPersonalInfo({ ...personalInfo, lastName: e.target.value })
|
||||
}
|
||||
error={!!errors.lastName}
|
||||
errorMessage={errors.lastName}
|
||||
placeholder="Racer"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="displayName" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Display Name * <span className="text-gray-500 font-normal">(shown publicly)</span>
|
||||
</label>
|
||||
<Input
|
||||
id="displayName"
|
||||
type="text"
|
||||
value={personalInfo.displayName}
|
||||
onChange={(e) =>
|
||||
setPersonalInfo({ ...personalInfo, displayName: e.target.value })
|
||||
}
|
||||
error={!!errors.displayName}
|
||||
errorMessage={errors.displayName}
|
||||
placeholder="SpeedyRacer42"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="country" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Country *
|
||||
</label>
|
||||
<CountrySelect
|
||||
value={personalInfo.country}
|
||||
onChange={(value) =>
|
||||
setPersonalInfo({ ...personalInfo, country: value })
|
||||
}
|
||||
error={!!errors.country}
|
||||
errorMessage={errors.country ?? ''}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="timezone" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Timezone
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 z-10" />
|
||||
<select
|
||||
id="timezone"
|
||||
value={personalInfo.timezone}
|
||||
onChange={(e) =>
|
||||
setPersonalInfo({ ...personalInfo, timezone: e.target.value })
|
||||
}
|
||||
className="block w-full rounded-md border-0 px-4 py-3 pl-10 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm appearance-none cursor-pointer"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">Select timezone</option>
|
||||
{TIMEZONES.map((tz) => (
|
||||
<option key={tz.value} value={tz.value}>
|
||||
{tz.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronRight className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 rotate-90" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user