This commit is contained in:
2026-01-15 01:26:30 +01:00
parent 4a2d7d15a5
commit c3b308e960
102 changed files with 2532 additions and 4744 deletions

View File

@@ -1,36 +1,50 @@
'use client';
import React from 'react';
import { useRouter } from 'next/navigation';
import { DriversTemplate } from '@/templates/DriversTemplate';
import { useDriverSearch } from '@/lib/hooks/useDriverSearch';
import type { DriverLeaderboardViewModel } from '@/lib/view-data/DriverLeaderboardViewModel';
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
interface DriversPageClientProps {
data: DriverLeaderboardViewModel | null;
pageDto: DriverLeaderboardViewModel | null;
error?: string;
empty?: {
title: string;
description: string;
};
}
export function DriversPageClient({ data }: DriversPageClientProps) {
const router = useRouter();
const drivers = data?.drivers || [];
const { searchQuery, setSearchQuery, filteredDrivers } = useDriverSearch(drivers);
/**
* DriversPageClient
*
* Client component that:
* 1. Passes ViewModel directly to Template
*
* No business logic, filtering, or sorting here.
* All data transformation happens in the PageQuery and ViewModelBuilder.
*/
export function DriversPageClient({ pageDto, error, empty }: DriversPageClientProps) {
// Handle error/empty states
if (error) {
return (
<div className="max-w-7xl mx-auto px-4 py-12 text-center">
<div className="text-red-400 mb-4">Error loading drivers</div>
<p className="text-gray-400">Please try again later</p>
</div>
);
}
const handleDriverClick = (driverId: string) => {
router.push(`/drivers/${driverId}`);
};
if (!pageDto || pageDto.drivers.length === 0) {
if (empty) {
return (
<div className="max-w-7xl mx-auto px-4 py-12 text-center">
<h2 className="text-xl font-semibold text-white mb-2">{empty.title}</h2>
<p className="text-gray-400">{empty.description}</p>
</div>
);
}
return null;
}
const handleViewLeaderboard = () => {
router.push('/leaderboards/drivers');
};
return (
<DriversTemplate
data={data}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
filteredDrivers={filteredDrivers}
onDriverClick={handleDriverClick}
onViewLeaderboard={handleViewLeaderboard}
/>
);
}
// Pass ViewModel directly to template
return <DriversTemplate data={pageDto} />;
}

View File

@@ -19,10 +19,17 @@ import {
Timer,
} from 'lucide-react';
import LeagueCard from '@/components/leagues/LeagueCard';
import Button from '@/ui/Button';
import Card from '@/ui/Card';
import Input from '@/ui/Input';
import Heading from '@/ui/Heading';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Input } from '@/ui/Input';
import { Heading } from '@/ui/Heading';
import { Container } from '@/ui/Container';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { HeroSection } from '@/components/shared/HeroSection';
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
@@ -459,91 +466,61 @@ export function LeaguesClient({
];
return (
<div className="max-w-7xl mx-auto px-4 pb-12">
<Container size="lg" pb={12}>
{/* Hero Section */}
<div className="relative mb-10 py-10 px-8 rounded-2xl bg-gradient-to-br from-iron-gray/80 via-deep-graphite to-iron-gray/60 border border-charcoal-outline/50 overflow-hidden">
{/* Background decoration */}
<div className="absolute top-0 right-0 w-96 h-96 bg-primary-blue/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-64 h-64 bg-neon-aqua/5 rounded-full blur-3xl" />
<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">
<Trophy className="w-6 h-6 text-primary-blue" />
</div>
<Heading level={1} className="text-3xl lg:text-4xl">
Find Your Grid
</Heading>
</div>
<p className="text-gray-400 text-lg leading-relaxed mb-6">
From casual sprints to epic endurance battles discover the perfect league for your racing style.
</p>
{/* Stats */}
<div className="flex flex-wrap gap-6">
<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">{viewData.leagues.length}</span> active leagues
</span>
</div>
<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">{leaguesByCategory.new.length}</span> new this week
</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">{leaguesByCategory.openSlots.length}</span> with open slots
</span>
</div>
</div>
</div>
{/* CTA */}
<div className="flex flex-col gap-4">
<a href=routes.league.detail('create') className="flex items-center gap-2 px-6 py-3 bg-primary-blue text-white rounded-lg hover:bg-blue-600 transition-colors">
<Plus className="w-5 h-5" />
<span>Create League</span>
</a>
<p className="text-xs text-gray-500 text-center">Set up your own racing series</p>
</div>
</div>
</div>
<HeroSection
title="Find Your Grid"
description="From casual sprints to epic endurance battles — discover the perfect league for your racing style."
icon={Trophy}
stats={[
{ value: viewData.leagues.length, label: 'active leagues', color: 'bg-performance-green', animate: true },
{ value: leaguesByCategory.new.length, label: 'new this week', color: 'bg-primary-blue' },
{ value: leaguesByCategory.openSlots.length, label: 'with open slots', color: 'bg-neon-aqua' },
]}
actions={[
{
label: 'Create League',
onClick: () => { window.location.href = '/leagues/create'; },
icon: Plus,
description: 'Set up your own racing series'
}
]}
/>
{/* Search and Filter Bar */}
<div className="mb-6">
<div className="flex flex-col lg:flex-row gap-4">
<Box mb={6}>
<Stack direction="row" gap={4} wrap>
{/* Search */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<Box display="flex" position="relative" style={{ flex: 1 }}>
<Box position="absolute" style={{ left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Search className="w-5 h-5 text-gray-500" />
</Box>
<Input
type="text"
placeholder="Search leagues by name, description, or game..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
className="pl-11"
/>
</div>
</Box>
{/* Filter toggle (mobile) */}
<Button
type="button"
variant="secondary"
onClick={() => setShowFilters(!showFilters)}
className="lg:hidden flex items-center gap-2"
>
<Filter className="w-4 h-4" />
Filters
</Button>
</div>
<Box display="none" className="lg:hidden">
<Button
type="button"
variant="secondary"
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2"
>
<Filter className="w-4 h-4" />
Filters
</Button>
</Box>
</Stack>
{/* Category Tabs */}
<div className={`mt-4 ${showFilters ? 'block' : 'hidden lg:block'}`}>
<div className="flex flex-wrap gap-2">
<Box mt={4} display={showFilters ? 'block' : 'none'} className="lg:block">
<Stack direction="row" gap={2} wrap>
{CATEGORIES.map((category) => {
const Icon = category.icon;
const count = leaguesByCategory[category.id].length;
@@ -570,33 +547,36 @@ export function LeaguesClient({
</button>
);
})}
</div>
</div>
</div>
</Stack>
</Box>
</Box>
{/* Content */}
{viewData.leagues.length === 0 ? (
/* Empty State */
<Card className="text-center py-16">
<div className="max-w-md mx-auto">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-2xl bg-primary-blue/10 border border-primary-blue/20 mb-6">
<Box maxWidth="28rem" mx="auto">
<Box display="flex" center mb={6} rounded="2xl" p={4} className="bg-primary-blue/10 border border-primary-blue/20 mx-auto w-16 h-16">
<Trophy className="w-8 h-8 text-primary-blue" />
</div>
</Box>
<Heading level={2} className="text-2xl mb-3">
No leagues yet
</Heading>
<p className="text-gray-400 mb-8">
<Text color="text-gray-400" mb={8} block>
Be the first to create a racing series. Start your own league and invite drivers to compete for glory.
</p>
<a href=routes.league.detail('create') className="inline-flex items-center gap-2 px-6 py-3 bg-primary-blue text-white rounded-lg hover:bg-blue-600 transition-colors">
</Text>
<Button
onClick={() => { window.location.href = '/leagues/create'; }}
className="inline-flex items-center gap-2 px-6 py-3 bg-primary-blue text-white rounded-lg hover:bg-blue-600 transition-colors"
>
<Sparkles className="w-4 h-4" />
Create Your First League
</a>
</div>
</Button>
</Box>
</Card>
) : activeCategory === 'all' && !searchQuery ? (
/* Slider View - Show featured categories with sliders at different speeds and directions */
<div>
<Box>
{featuredCategoriesWithSpeed
.map(({ id, speed, direction }) => {
const category = CATEGORIES.find((c) => c.id === id)!;
@@ -616,25 +596,25 @@ export function LeaguesClient({
scrollDirection={direction}
/>
))}
</div>
</Box>
) : (
/* Grid View - Filtered by category or search */
<div>
<Box>
{categoryFilteredLeagues.length > 0 ? (
<>
<div className="flex items-center justify-between mb-6">
<p className="text-sm text-gray-400">
Showing <span className="text-white font-medium">{categoryFilteredLeagues.length}</span>{' '}
<Box display="flex" align="center" justify="between" mb={6}>
<Text size="sm" color="text-gray-400">
Showing <Text color="text-white" weight="medium">{categoryFilteredLeagues.length}</Text>{' '}
{categoryFilteredLeagues.length === 1 ? 'league' : 'leagues'}
{searchQuery && (
<span>
{' '}
for "<span className="text-primary-blue">{searchQuery}</span>"
for "<Text color="text-primary-blue">{searchQuery}</Text>"
</span>
)}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
</Text>
</Box>
<Grid cols={1} mdCols={2} lgCols={3} gap={6}>
{categoryFilteredLeagues.map((league) => {
// Convert ViewData to ViewModel for LeagueCard
const viewModel: LeagueSummaryViewModel = {
@@ -658,20 +638,22 @@ export function LeaguesClient({
};
return (
<a key={league.id} href={`/leagues/${league.id}`} className="block h-full">
<LeagueCard league={viewModel} />
</a>
<GridItem key={league.id}>
<a href={`/leagues/${league.id}`} className="block h-full">
<LeagueCard league={viewModel} />
</a>
</GridItem>
);
})}
</div>
</Grid>
</>
) : (
<Card className="text-center py-12">
<div className="flex flex-col items-center gap-4">
<Stack align="center" gap={4}>
<Search className="w-10 h-10 text-gray-600" />
<p className="text-gray-400">
<Text color="text-gray-400">
No leagues found{searchQuery ? ` matching "${searchQuery}"` : ' in this category'}
</p>
</Text>
<Button
variant="secondary"
onClick={() => {
@@ -681,11 +663,11 @@ export function LeaguesClient({
>
Clear filters
</Button>
</div>
</Stack>
</Card>
)}
</div>
</Box>
)}
</div>
</Container>
);
}

View File

@@ -4,9 +4,9 @@ import PenaltyFAB from '@/components/leagues/PenaltyFAB';
import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal';
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
import StewardingStats from '@/components/leagues/StewardingStats';
import Button from '@/ui/Button';
import Card from '@/ui/Card';
import { useLeagueStewardingMutations } from "@/lib/hooks/league/useLeagueStewardingMutations";
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { useLeagueStewardingMutations } from "@/hooks/league/useLeagueStewardingMutations";
import {
AlertCircle,
AlertTriangle,
@@ -19,13 +19,15 @@ import {
} from 'lucide-react';
import Link from 'next/link';
import { useMemo, useState } from 'react';
import { PendingProtestsList } from '@/components/leagues/PendingProtestsList';
import { PenaltyHistoryList } from '@/components/leagues/PenaltyHistoryList';
interface StewardingData {
totalPending: number;
totalResolved: number;
totalPenalties: number;
racesWithData: Array<{
race: { id: string; track: string; scheduledAt: Date };
race: { id: string; track: string; scheduledAt: Date; car?: string };
pendingProtests: any[];
resolvedProtests: any[];
penalties: any[];
@@ -44,16 +46,27 @@ interface StewardingTemplateProps {
export function StewardingTemplate({ data, leagueId, currentDriverId, onRefetch }: StewardingTemplateProps) {
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [selectedProtest, setSelectedProtest] = useState<any | null>(null);
const [expandedRaces, setExpandedRaces] = useState<Set<string>>(new Set());
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
// Mutations using domain hook
const { acceptProtestMutation, rejectProtestMutation } = useLeagueStewardingMutations(onRefetch);
// Filter races based on active tab
const filteredRaces = useMemo(() => {
return activeTab === 'pending' ? data.racesWithData.filter(r => r.pendingProtests.length > 0) : data.racesWithData.filter(r => r.resolvedProtests.length > 0 || r.penalties.length > 0);
}, [data, activeTab]);
// Flatten protests for the specialized list components
const allPendingProtests = useMemo(() => {
return data.racesWithData.flatMap(r => r.pendingProtests);
}, [data]);
const allResolvedProtests = useMemo(() => {
return data.racesWithData.flatMap(r => r.resolvedProtests);
}, [data]);
const racesMap = useMemo(() => {
const map: Record<string, any> = {};
data.racesWithData.forEach(r => {
map[r.race.id] = r.race;
});
return map;
}, [data]);
const handleAcceptProtest = async (
protestId: string,
@@ -89,34 +102,6 @@ export function StewardingTemplate({ data, leagueId, currentDriverId, onRefetch
});
};
const toggleRaceExpanded = (raceId: string) => {
setExpandedRaces(prev => {
const next = new Set(prev);
if (next.has(raceId)) {
next.delete(raceId);
} else {
next.add(raceId);
}
return next;
});
};
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;
}
};
return (
<div className="space-y-6">
<Card>
@@ -168,168 +153,21 @@ export function StewardingTemplate({ data, leagueId, currentDriverId, onRefetch
</div>
{/* Content */}
{filteredRaces.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">
{activeTab === 'pending' ? 'All Clear!' : 'No History Yet'}
</p>
<p className="text-sm text-gray-400">
{activeTab === 'pending'
? 'No pending protests to review'
: 'No resolved protests or penalties'}
</p>
</div>
{activeTab === 'pending' ? (
<PendingProtestsList
protests={allPendingProtests}
races={racesMap}
drivers={data.driverMap}
leagueId={leagueId}
onReviewProtest={setSelectedProtest}
onProtestReviewed={onRefetch}
/>
) : (
<div className="space-y-4">
{filteredRaces.map(({ race, pendingProtests, resolvedProtests, penalties }) => {
const isExpanded = expandedRaces.has(race.id);
const displayProtests = activeTab === 'pending' ? pendingProtests : resolvedProtests;
return (
<div key={race.id} className="rounded-lg border border-charcoal-outline overflow-hidden">
{/* Race Header */}
<button
onClick={() => toggleRaceExpanded(race.id)}
className="w-full px-4 py-3 bg-iron-gray/30 hover:bg-iron-gray/50 transition-colors flex items-center justify-between"
>
<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>{race.scheduledAt.toLocaleDateString()}</span>
</div>
{activeTab === 'pending' && pendingProtests.length > 0 && (
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
{pendingProtests.length} pending
</span>
)}
{activeTab === 'history' && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">
{resolvedProtests.length} protests, {penalties.length} penalties
</span>
)}
</div>
<ChevronRight className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="p-4 space-y-3 bg-deep-graphite/50">
{displayProtests.length === 0 && penalties.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No items to display</p>
) : (
<>
{displayProtests.map((protest) => {
const protester = data.driverMap[protest.protestingDriverId];
const accused = data.driverMap[protest.accusedDriverId];
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24));
const isUrgent = daysSinceFiled > 2 && (protest.status === 'pending' || protest.status === 'under_review');
return (
<div
key={protest.id}
className={`rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4 ${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" />
<span className="font-medium text-white">
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
</span>
{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 {new Date(protest.filedAt).toLocaleDateString()}</span>
{protest.proofVideoUrl && (
<>
<span></span>
<span className="flex items-center gap-1 text-primary-blue">
<Video className="w-3 h-3" />
Video
</span>
</>
)}
</div>
<p className="text-sm text-gray-300 line-clamp-2">
{protest.incident.description}
</p>
{protest.decisionNotes && (
<div className="mt-2 p-2 rounded bg-iron-gray/50 border border-charcoal-outline/50">
<p className="text-xs text-gray-400">
<span className="font-medium">Steward:</span> {protest.decisionNotes}
</p>
</div>
)}
</div>
{(protest.status === 'pending' || protest.status === 'under_review') && (
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
<Button variant="primary">
Review
</Button>
</Link>
)}
</div>
</div>
);
})}
{activeTab === 'history' && penalties.map((penalty) => {
const driver = data.driverMap[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>
<PenaltyHistoryList
protests={allResolvedProtests}
races={racesMap}
drivers={data.driverMap}
/>
)}
</Card>

View File

@@ -1,23 +1,55 @@
import Link from 'next/link';
import Button from '@/ui/Button';
import Card from '@/ui/Card';
import Container from '@/ui/Container';
import Heading from '@/ui/Heading';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Grid } from '@/ui/Grid';
import { routes } from '@/lib/routing/RouteConfig';
import { LiveryCard } from '@/components/profile/LiveryCard';
export default async function ProfileLiveriesPage() {
const mockLiveries = [
{
id: '1',
carId: 'gt3-r',
carName: 'Porsche 911 GT3 R (992)',
thumbnailUrl: '',
uploadedAt: new Date(),
isValidated: true,
},
{
id: '2',
carId: 'f3',
carName: 'Dallara F3',
thumbnailUrl: '',
uploadedAt: new Date(),
isValidated: false,
}
];
return (
<Container size="md">
<Heading level={1}>Liveries</Heading>
<Card>
<p>Livery management is currently unavailable.</p>
<Link href={routes.protected.profile}>
<Button variant="secondary">Back to profile</Button>
</Link>
<Container size="lg" py={8}>
<div className="flex items-center justify-between mb-8">
<div>
<Heading level={1}>My Liveries</Heading>
<p className="text-gray-400 mt-1">Manage your custom car liveries</p>
</div>
<Link href={routes.protected.profileLiveryUpload}>
<Button variant="primary">Upload livery</Button>
</Link>
</Card>
</div>
<Grid cols={3} gap={6}>
{mockLiveries.map((livery) => (
<LiveryCard key={livery.id} livery={livery} />
))}
</Grid>
<div className="mt-12">
<Link href={routes.protected.profile}>
<Button variant="secondary">Back to profile</Button>
</Link>
</div>
</Container>
);
}

View File

@@ -1,7 +1,12 @@
import React from 'react';
import { Search, Star, Trophy, Percent, Hash } from 'lucide-react';
import Button from '@/ui/Button';
import Input from '@/ui/Input';
import { Search, Star, Trophy, Percent, Hash, LucideIcon } from 'lucide-react';
import { Button } from '@/ui/Button';
import { Input } from '@/ui/Input';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Badge } from '@/ui/Badge';
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
@@ -9,17 +14,15 @@ type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
const SKILL_LEVELS: {
id: SkillLevel;
label: string;
color: string;
bgColor: string;
borderColor: string;
variant: 'warning' | 'primary' | 'info' | 'success';
}[] = [
{ id: 'pro', label: 'Pro', color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30' },
{ id: 'beginner', label: 'Beginner', color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30' },
{ id: 'pro', label: 'Pro', variant: 'warning' },
{ id: 'advanced', label: 'Advanced', variant: 'primary' },
{ id: 'intermediate', label: 'Intermediate', variant: 'info' },
{ id: 'beginner', label: 'Beginner', variant: 'success' },
];
const SORT_OPTIONS: { id: SortBy; label: string; icon: React.ElementType }[] = [
const SORT_OPTIONS: { id: SortBy; label: string; icon: LucideIcon }[] = [
{ id: 'rating', label: 'Rating', icon: Star },
{ id: 'wins', label: 'Total Wins', icon: Trophy },
{ id: 'winRate', label: 'Win Rate', icon: Percent },
@@ -35,7 +38,7 @@ interface TeamRankingsFilterProps {
onSortChange: (sort: SortBy) => void;
}
export default function TeamRankingsFilter({
export function TeamRankingsFilter({
searchQuery,
onSearchChange,
filterLevel,
@@ -44,76 +47,70 @@ export default function TeamRankingsFilter({
onSortChange,
}: TeamRankingsFilterProps) {
return (
<div className="mb-6 space-y-4">
<Stack mb={6} gap={4}>
{/* Search and Level Filter Row */}
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1 relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<Stack direction="row" align="center" gap={4} wrap>
<Box maxWidth="448px" fullWidth>
<Input
type="text"
placeholder="Search teams..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-11"
icon={<Icon icon={Search} size={5} color="text-gray-500" />}
/>
</div>
</Box>
{/* Level Filter */}
<div className="flex items-center gap-2">
<button
type="button"
<Stack direction="row" align="center" gap={2} wrap>
<Button
variant={filterLevel === 'all' ? 'race-final' : 'secondary'}
size="sm"
onClick={() => onFilterLevelChange('all')}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
filterLevel === 'all'
? 'bg-purple-600 text-white'
: 'bg-iron-gray/50 text-gray-400 border border-charcoal-outline hover:text-white'
}`}
>
All Levels
</button>
</Button>
{SKILL_LEVELS.map((level) => {
const isActive = filterLevel === level.id;
return (
<button
<Button
key={level.id}
type="button"
variant={isActive ? 'primary' : 'secondary'}
size="sm"
onClick={() => onFilterLevelChange(level.id)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-all ${
filterLevel === level.id
? `${level.bgColor} ${level.color} border ${level.borderColor}`
: 'bg-iron-gray/50 text-gray-400 border border-charcoal-outline hover:text-white'
}`}
>
{level.label}
</button>
{isActive ? (
<Badge variant={level.variant}>{level.label}</Badge>
) : (
level.label
)}
</Button>
);
})}
</div>
</div>
</Stack>
</Stack>
{/* Sort Options */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Sort by:</span>
<div className="flex items-center gap-1 p-1 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
{SORT_OPTIONS.map((option) => {
const OptionIcon = option.icon;
return (
<button
key={option.id}
type="button"
onClick={() => onSortChange(option.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
sortBy === option.id
? 'bg-purple-600 text-white'
: 'text-gray-400 hover:text-white hover:bg-iron-gray'
}`}
>
<OptionIcon className="w-3.5 h-3.5" />
{option.label}
</button>
);
})}
</div>
</div>
</div>
<Stack direction="row" align="center" gap={2}>
<Text size="sm" color="text-gray-400">Sort by:</Text>
<Box p={1} rounded="lg" border borderColor="charcoal-outline" backgroundColor="iron-gray" opacity={0.5}>
<Stack direction="row" align="center" gap={1}>
{SORT_OPTIONS.map((option) => {
const isActive = sortBy === option.id;
return (
<Button
key={option.id}
variant={isActive ? 'race-final' : 'ghost'}
size="sm"
onClick={() => onSortChange(option.id)}
icon={<Icon icon={option.icon} size={3.5} />}
>
{option.label}
</Button>
);
})}
</Stack>
</Box>
</Stack>
</Stack>
);
}
}

View File

@@ -1,63 +0,0 @@
import React from 'react';
import { Trophy, Medal, Star, Crown, Target, Zap } from 'lucide-react';
import type { DriverProfileAchievementViewModel } from '@/lib/view-models/DriverProfileViewModel';
interface AchievementCardProps {
achievement: DriverProfileAchievementViewModel;
}
function getRarityColor(rarity: DriverProfileAchievementViewModel['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: DriverProfileAchievementViewModel['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;
}
}
export default function AchievementCard({ achievement }: AchievementCardProps) {
const Icon = getAchievementIcon(achievement.icon);
const rarityClasses = getRarityColor(achievement.rarity);
return (
<div 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">
{new Date(achievement.earnedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
</div>
</div>
</div>
);
}

View File

@@ -4,7 +4,6 @@ import React from 'react';
import { Filter, Search } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { Grid } from '@/ui/Grid';
@@ -51,18 +50,13 @@ export function UserFilters({
</Stack>
<Grid cols={3} gap={4}>
<Box position="relative">
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Icon icon={Search} size={4} color="#9ca3af" />
</Box>
<Input
type="text"
placeholder="Search by email or name..."
value={search}
onChange={(e) => onSearch(e.target.value)}
style={{ paddingLeft: '2.25rem' }}
/>
</Box>
<Input
type="text"
placeholder="Search by email or name..."
value={search}
onChange={(e) => onSearch(e.target.value)}
icon={<Icon icon={Search} size={4} color="#9ca3af" />}
/>
<Select
value={roleFilter}

View File

@@ -3,7 +3,6 @@
import React from 'react';
import { Users, Shield } from 'lucide-react';
import { Grid } from '@/ui/Grid';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
@@ -19,7 +18,7 @@ interface UserStatsSummaryProps {
export function UserStatsSummary({ total, activeCount, adminCount }: UserStatsSummaryProps) {
return (
<Grid cols={3} gap={4}>
<Surface variant="muted" rounded="xl" border padding={4} style={{ background: 'linear-gradient(to bottom right, rgba(30, 58, 138, 0.2), rgba(29, 78, 216, 0.1))', borderColor: 'rgba(59, 130, 246, 0.2)' }}>
<Surface variant="gradient-blue" rounded="xl" border padding={4}>
<Stack direction="row" align="center" justify="between">
<Box>
<Text size="sm" color="text-gray-400" block mb={1}>Total Users</Text>
@@ -28,7 +27,7 @@ export function UserStatsSummary({ total, activeCount, adminCount }: UserStatsSu
<Icon icon={Users} size={6} color="#60a5fa" />
</Stack>
</Surface>
<Surface variant="muted" rounded="xl" border padding={4} style={{ background: 'linear-gradient(to bottom right, rgba(20, 83, 45, 0.2), rgba(21, 128, 61, 0.1))', borderColor: 'rgba(16, 185, 129, 0.2)' }}>
<Surface variant="gradient-green" rounded="xl" border padding={4}>
<Stack direction="row" align="center" justify="between">
<Box>
<Text size="sm" color="text-gray-400" block mb={1}>Active</Text>
@@ -37,7 +36,7 @@ export function UserStatsSummary({ total, activeCount, adminCount }: UserStatsSu
<Text color="text-performance-green" weight="bold"></Text>
</Stack>
</Surface>
<Surface variant="muted" rounded="xl" border padding={4} style={{ background: 'linear-gradient(to bottom right, rgba(88, 28, 135, 0.2), rgba(126, 34, 206, 0.1))', borderColor: 'rgba(168, 85, 247, 0.2)' }}>
<Surface variant="gradient-purple" rounded="xl" border padding={4}>
<Stack direction="row" align="center" justify="between">
<Box>
<Text size="sm" color="text-gray-400" block mb={1}>Admins</Text>

View File

@@ -8,14 +8,18 @@ import {
Settings,
Trophy,
Car,
Users,
Shield,
CheckCircle2
CheckCircle2,
LucideIcon
} from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
interface WorkflowStep {
id: number;
icon: typeof UserPlus;
icon: LucideIcon;
title: string;
description: string;
color: string;
@@ -59,7 +63,7 @@ const WORKFLOW_STEPS: WorkflowStep[] = [
},
];
export default function AuthWorkflowMockup() {
export function AuthWorkflowMockup() {
const shouldReduceMotion = useReducedMotion();
const [isMounted, setIsMounted] = useState(false);
const [activeStep, setActiveStep] = useState(0);
@@ -80,40 +84,40 @@ export default function AuthWorkflowMockup() {
if (!isMounted) {
return (
<div className="relative w-full">
<div className="bg-iron-gray/50 rounded-2xl border border-charcoal-outline p-6">
<div className="flex justify-between gap-2">
<Box position="relative" fullWidth>
<Surface variant="muted" rounded="2xl" border padding={6}>
<Stack direction="row" justify="between" gap={2}>
{WORKFLOW_STEPS.map((step) => (
<div key={step.id} className="flex flex-col items-center text-center flex-1">
<div className="w-10 h-10 rounded-lg bg-iron-gray border border-charcoal-outline flex items-center justify-center mb-2">
<step.icon className={`w-4 h-4 ${step.color}`} />
</div>
<h4 className="text-xs font-medium text-white">{step.title}</h4>
</div>
<Stack key={step.id} align="center" center>
<Box width={10} height={10} rounded="lg" backgroundColor="iron-gray" border borderColor="charcoal-outline" display="flex" center mb={2}>
<Icon icon={step.icon} size={4} className={step.color} />
</Box>
<Text size="xs" weight="medium" color="text-white">{step.title}</Text>
</Stack>
))}
</div>
</div>
</div>
</Stack>
</Surface>
</Box>
);
}
return (
<div className="relative w-full">
<div className="bg-iron-gray/50 rounded-2xl border border-charcoal-outline p-4 sm:p-6 overflow-hidden">
<Box position="relative" fullWidth>
<Surface variant="muted" rounded="2xl" border padding={6} className="overflow-hidden">
{/* Connection Lines */}
<div className="absolute top-[3.5rem] left-[8%] right-[8%] hidden sm:block">
<div className="h-0.5 bg-charcoal-outline relative">
<Box position="absolute" top="3.5rem" left="8%" right="8%" className="hidden sm:block">
<Box height={0.5} backgroundColor="charcoal-outline" position="relative">
<motion.div
className="absolute h-full bg-gradient-to-r from-primary-blue to-performance-green"
initial={{ width: '0%' }}
animate={{ width: `${(activeStep / (WORKFLOW_STEPS.length - 1)) * 100}%` }}
transition={{ duration: 0.5, ease: 'easeInOut' }}
/>
</div>
</div>
</Box>
</Box>
{/* Steps */}
<div className="flex justify-between gap-2 relative">
<Stack direction="row" justify="between" gap={2} position="relative">
{WORKFLOW_STEPS.map((step, index) => {
const isActive = index === activeStep;
const isCompleted = index < activeStep;
@@ -141,20 +145,20 @@ export default function AuthWorkflowMockup() {
} : {}}
>
{isCompleted ? (
<CheckCircle2 className="w-4 h-4 sm:w-5 sm:h-5 text-performance-green" />
<Icon icon={CheckCircle2} size={5} color="text-performance-green" />
) : (
<StepIcon className={`w-4 h-4 sm:w-5 sm:h-5 ${isActive ? step.color : 'text-gray-500'}`} />
<Icon icon={StepIcon} size={5} className={isActive ? step.color : 'text-gray-500'} />
)}
</motion.div>
<h4 className={`text-xs font-medium transition-colors hidden sm:block ${
<Text size="xs" weight="medium" className={`transition-colors hidden sm:block ${
isActive ? 'text-white' : 'text-gray-400'
}`}>
{step.title}
</h4>
</Text>
</motion.div>
);
})}
</div>
</Stack>
{/* Active Step Preview - Mobile */}
<AnimatePresence mode="wait">
@@ -166,17 +170,17 @@ export default function AuthWorkflowMockup() {
transition={{ duration: 0.2 }}
className="mt-4 pt-4 border-t border-charcoal-outline sm:hidden"
>
<div className="text-center">
<p className="text-xs text-gray-400 mb-1">
<Box textAlign="center">
<Text size="xs" color="text-gray-400" block mb={1}>
Step {activeStep + 1}: {WORKFLOW_STEPS[activeStep]?.title || ''}
</p>
<p className="text-xs text-gray-500">
</Text>
<Text size="xs" color="text-gray-500" block>
{WORKFLOW_STEPS[activeStep]?.description || ''}
</p>
</div>
</Text>
</Box>
</motion.div>
</AnimatePresence>
</div>
</div>
</Surface>
</Box>
);
}
}

View File

@@ -1,82 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { LucideIcon } from 'lucide-react';
interface RoleCardProps {
icon: LucideIcon;
title: string;
description: string;
features: string[];
color: string;
selected?: boolean;
onClick?: () => void;
}
export default function RoleCard({
icon: Icon,
title,
description,
features,
color,
selected = false,
onClick,
}: RoleCardProps) {
return (
<motion.button
onClick={onClick}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={`w-full text-left p-4 rounded-xl border transition-all duration-200 ${
selected
? `border-${color} bg-${color}/10 shadow-[0_0_20px_rgba(25,140,255,0.2)]`
: 'border-charcoal-outline bg-iron-gray/50 hover:border-gray-600 hover:bg-iron-gray'
}`}
>
<div className="flex items-start gap-3">
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors ${
selected ? `bg-${color}/20` : 'bg-deep-graphite'
}`}
>
<Icon className={`w-5 h-5 ${selected ? `text-${color}` : 'text-gray-400'}`} />
</div>
<div className="flex-1 min-w-0">
<h3
className={`font-semibold transition-colors ${
selected ? 'text-white' : 'text-gray-200'
}`}
>
{title}
</h3>
<p className="text-xs text-gray-500 mt-0.5">{description}</p>
</div>
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all ${
selected ? 'border-primary-blue bg-primary-blue' : 'border-gray-600'
}`}
>
{selected && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="w-2 h-2 rounded-full bg-white"
/>
)}
</div>
</div>
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
<ul className="space-y-1">
{features.map((feature, index) => (
<li key={index} className="text-xs text-gray-400 flex items-center gap-2">
<span
className={`w-1 h-1 rounded-full ${selected ? 'bg-primary-blue' : 'bg-gray-600'}`}
/>
{feature}
</li>
))}
</ul>
</div>
</motion.button>
);
}

View File

@@ -1,6 +1,11 @@
import React from 'react';
import { motion } from 'framer-motion';
import { Car, Trophy, Users } 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';
const USER_ROLES = [
{
@@ -21,50 +26,77 @@ const USER_ROLES = [
description: 'Manage team and drivers',
color: 'purple-400',
},
];
] as const;
interface UserRolesPreviewProps {
variant?: 'full' | 'compact';
}
export default function UserRolesPreview({ variant = 'full' }: UserRolesPreviewProps) {
export function UserRolesPreview({ variant = 'full' }: UserRolesPreviewProps) {
if (variant === 'compact') {
return (
<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} display={{ base: 'block', lg: 'none' }}>
<Text align="center" size="xs" color="text-gray-500" mb={4} block>
One account for all roles
</Text>
<Stack direction="row" 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} direction="col" align="center">
<Box
width="8"
height="8"
rounded="lg"
bg={`bg-${role.color}/20`}
display="flex"
alignItems="center"
justifyContent="center"
mb={1}
>
<Icon icon={role.icon} size={4} className={`text-${role.color}`} />
</Box>
<Text size="xs" color="text-gray-500">{role.title}</Text>
</Stack>
))}
</div>
</div>
</Stack>
</Box>
);
}
return (
<div className="space-y-3 mb-8">
<Stack direction="col" gap={3} mb={8}>
{USER_ROLES.map((role, index) => (
<motion.div
<Box
as={motion.div}
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"
display="flex"
alignItems="center"
gap={4}
p={4}
rounded="xl"
bg="bg-iron-gray/50"
border
borderColor="border-charcoal-outline"
>
<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>
<Box
width="10"
height="10"
rounded="lg"
bg={`bg-${role.color}/20`}
display="flex"
alignItems="center"
justifyContent="center"
>
<Icon icon={role.icon} size={5} className={`text-${role.color}`} />
</Box>
<Box>
<Heading level={4}>{role.title}</Heading>
<Text size="sm" color="text-gray-500">{role.description}</Text>
</Box>
</Box>
))}
</div>
</Stack>
);
}
}

View File

@@ -1,3 +1,5 @@
'use client';
import React from 'react';
interface CircularProgressProps {
@@ -8,7 +10,7 @@ interface CircularProgressProps {
size?: number;
}
export default function CircularProgress({ value, max, label, color, size = 80 }: CircularProgressProps) {
export function CircularProgress({ value, max, label, color, size = 80 }: CircularProgressProps) {
const percentage = Math.min((value / max) * 100, 100);
const strokeWidth = 6;
const radius = (size - strokeWidth) / 2;

View File

@@ -1,3 +1,5 @@
'use client';
import React from 'react';
interface BarChartProps {
@@ -5,7 +7,7 @@ interface BarChartProps {
maxValue: number;
}
export default function HorizontalBarChart({ data, maxValue }: BarChartProps) {
export function HorizontalBarChart({ data, maxValue }: BarChartProps) {
return (
<div className="space-y-3">
{data.map((item) => (

View File

@@ -1,8 +1,7 @@
'use client';
import React from 'react';
import { Trophy, Medal, Target, Users, LucideIcon } from 'lucide-react';
import { Card } from '@/ui/Card';
import { LucideIcon } from 'lucide-react';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
@@ -18,9 +17,9 @@ interface StatBoxProps {
export function StatBox({ icon, label, value, color }: StatBoxProps) {
return (
<Surface variant="muted" rounded="xl" border padding={4} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)', borderColor: '#262626', backdropFilter: 'blur(4px)' }}>
<Surface variant="muted" rounded="xl" border padding={4}>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: `${color}20`, color }}>
<Surface variant="muted" rounded="lg" padding={2}>
<Icon icon={icon} size={5} />
</Surface>
<Box>

View File

@@ -1,52 +0,0 @@
'use client';
interface CircularProgressProps {
value: number;
max: number;
label: string;
color: string;
size?: number;
}
export function CircularProgress({ value, max, label, color, size = 80 }: CircularProgressProps) {
const percentage = Math.min((value / max) * 100, 100);
const strokeWidth = 6;
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const strokeDashoffset = circumference - (percentage / 100) * circumference;
return (
<div className="flex flex-col items-center">
<div className="relative" style={{ width: size, height: size }}>
<svg className="transform -rotate-90" width={size} height={size}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="currentColor"
strokeWidth={strokeWidth}
fill="transparent"
className="text-charcoal-outline"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="currentColor"
strokeWidth={strokeWidth}
fill="transparent"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
className={color}
style={{ transition: 'stroke-dashoffset 0.5s ease-in-out' }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-lg font-bold text-white">{percentage.toFixed(0)}%</span>
</div>
</div>
<span className="text-xs text-gray-400 mt-2">{label}</span>
</div>
);
}

View File

@@ -1,87 +0,0 @@
'use client';
import React from 'react';
import { Users, Trophy, LucideIcon } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { DecorativeBlur } from '@/ui/DecorativeBlur';
interface StatItemProps {
label: string;
value: string | number;
color: string;
animate?: boolean;
}
function StatItem({ label, value, color, animate }: StatItemProps) {
return (
<Stack direction="row" align="center" gap={2}>
<Box style={{ width: '0.5rem', height: '0.5rem', borderRadius: '9999px', backgroundColor: color }} className={animate ? 'animate-pulse' : ''} />
<Text size="sm" color="text-gray-400">
<Text weight="semibold" color="text-white">{value}</Text> {label}
</Text>
</Stack>
);
}
interface DriversHeroProps {
driverCount: number;
activeCount: number;
totalWins: number;
totalRaces: number;
onViewLeaderboard: () => void;
}
export function DriversHero({
driverCount,
activeCount,
totalWins,
totalRaces,
onViewLeaderboard,
}: DriversHeroProps) {
return (
<Surface variant="muted" rounded="2xl" border padding={8} style={{ position: 'relative', overflow: 'hidden', background: 'linear-gradient(to bottom right, rgba(59, 130, 246, 0.2), rgba(38, 38, 38, 0.8), #0f1115)', borderColor: 'rgba(59, 130, 246, 0.3)' }}>
<DecorativeBlur color="blue" size="lg" position="top-right" opacity={10} />
<DecorativeBlur color="yellow" size="md" position="bottom-left" opacity={5} />
<Stack direction="row" align="center" justify="between" wrap gap={8} style={{ position: 'relative', zIndex: 10 }}>
<Box style={{ maxWidth: '42rem' }}>
<Stack direction="row" align="center" gap={3} mb={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={Users} size={6} color="#3b82f6" />
</Surface>
<Heading level={1}>Drivers</Heading>
</Stack>
<Text size="lg" color="text-gray-400" block mb={6} style={{ lineHeight: 1.625 }}>
Meet the racers who make every lap count. From rookies to champions, track their journey and see who's dominating the grid.
</Text>
{/* Quick Stats */}
<Stack direction="row" gap={6} wrap>
<StatItem label="drivers" value={driverCount} color="#3b82f6" />
<StatItem label="active" value={activeCount} color="#10b981" animate />
<StatItem label="total wins" value={totalWins.toLocaleString()} color="#f59e0b" />
<StatItem label="races" value={totalRaces.toLocaleString()} color="#00f2ff" />
</Stack>
</Box>
{/* CTA */}
<Stack align="center" gap={4}>
<Button
variant="primary"
onClick={onViewLeaderboard}
icon={<Icon icon={Trophy} size={5} />}
>
View Leaderboard
</Button>
<Text size="xs" color="text-gray-500">See full driver rankings</Text>
</Stack>
</Stack>
</Surface>
);
}

View File

@@ -1,50 +0,0 @@
'use client';
import React from 'react';
import { DriversTemplate } from '@/templates/DriversTemplate';
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
interface DriversPageClientProps {
pageDto: DriverLeaderboardViewModel | null;
error?: string;
empty?: {
title: string;
description: string;
};
}
/**
* DriversPageClient
*
* Client component that:
* 1. Passes ViewModel directly to Template
*
* No business logic, filtering, or sorting here.
* All data transformation happens in the PageQuery and ViewModelBuilder.
*/
export function DriversPageClient({ pageDto, error, empty }: DriversPageClientProps) {
// Handle error/empty states
if (error) {
return (
<div className="max-w-7xl mx-auto px-4 py-12 text-center">
<div className="text-red-400 mb-4">Error loading drivers</div>
<p className="text-gray-400">Please try again later</p>
</div>
);
}
if (!pageDto || pageDto.drivers.length === 0) {
if (empty) {
return (
<div className="max-w-7xl mx-auto px-4 py-12 text-center">
<h2 className="text-xl font-semibold text-white mb-2">{empty.title}</h2>
<p className="text-gray-400">{empty.description}</p>
</div>
);
}
return null;
}
// Pass ViewModel directly to template
return <DriversTemplate data={pageDto} />;
}

View File

@@ -1,84 +0,0 @@
import Heading from '@/ui/Heading';
import { Trophy, Users } from 'lucide-react';
import Button from '../ui/Button';
interface HeroSectionProps {
icon?: React.ElementType;
title: string;
description: string;
stats: Array<{
value: number | string;
label: string;
color: string;
animate?: boolean;
}>;
ctaLabel?: string;
ctaDescription?: string;
onCtaClick?: () => void;
className?: string;
}
export function HeroSection({
icon: Icon = Users,
title,
description,
stats,
ctaLabel = "View Leaderboard",
ctaDescription = "See full driver rankings",
onCtaClick,
className,
}: HeroSectionProps) {
return (
<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 ${className || ''}`}>
{/* 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" />
<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">
<Icon className="w-6 h-6 text-primary-blue" />
</div>
<Heading level={1} className="text-3xl lg:text-4xl">
{title}
</Heading>
</div>
<p className="text-gray-400 text-lg leading-relaxed mb-6">
{description}
</p>
{/* Quick Stats */}
<div className="flex flex-wrap gap-6">
{stats.map((stat, index) => (
<div key={index} className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${stat.color} ${stat.animate ? 'animate-pulse' : ''}`} />
<span className="text-sm text-gray-400">
<span className="text-white font-semibold">
{typeof stat.value === 'number' ? stat.value.toLocaleString() : stat.value}
</span> {stat.label}
</span>
</div>
))}
</div>
</div>
{/* CTA */}
{onCtaClick && (
<div className="flex flex-col gap-4">
<Button
variant="primary"
onClick={onCtaClick}
className="flex items-center gap-2 px-6 py-3"
>
<Trophy className="w-5 h-5" />
{ctaLabel}
</Button>
<p className="text-xs text-gray-500 text-center">{ctaDescription}</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,27 +0,0 @@
'use client';
interface HorizontalBarChartProps {
data: { label: string; value: number; color: string }[];
maxValue: number;
}
export function HorizontalBarChart({ data, maxValue }: HorizontalBarChartProps) {
return (
<div className="space-y-3">
{data.map((item) => (
<div key={item.label}>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-400">{item.label}</span>
<span className="text-white font-medium">{item.value}</span>
</div>
<div className="h-2 bg-charcoal-outline rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${item.color} transition-all duration-500 ease-out`}
style={{ width: `${Math.min((item.value / maxValue) * 100, 100)}%` }}
/>
</div>
</div>
))}
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import Card from '../ui/Card';
import { Card } from '@/ui/Card';
interface RatingBreakdownProps {
skillRating?: number;

View File

@@ -1,25 +1,34 @@
import Card from '@/ui/Card';
import Button from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
export default function FeedEmptyState() {
export function FeedEmptyState() {
return (
<Card className="bg-iron-gray/80 border-dashed border-charcoal-outline text-center py-10">
<div className="text-3xl mb-3">🏁</div>
<h3 className="text-lg font-semibold text-white mb-2">
Your feed is warming up
</h3>
<p className="text-sm text-gray-400 mb-4 max-w-md mx-auto">
As leagues, teams, and friends start racing, this feed will show their latest results,
signups, and highlights.
</p>
<Button
as="a"
href="/leagues"
variant="secondary"
className="text-xs px-4 py-2"
>
Explore leagues
</Button>
<Card className="bg-iron-gray/80 border-dashed border-charcoal-outline">
<Box textAlign="center" py={10}>
<Text size="3xl" block mb={3}>🏁</Text>
<Box mb={2}>
<Heading level={3}>
Your feed is warming up
</Heading>
</Box>
<Box maxWidth="md" mx="auto" mb={4}>
<Text size="sm" color="text-gray-400">
As leagues, teams, and friends start racing, this feed will show their latest results,
signups, and highlights.
</Text>
</Box>
<Button
as="a"
href="/leagues"
variant="secondary"
size="sm"
>
Explore leagues
</Button>
</Box>
</Card>
);
}
}

View File

@@ -1,186 +1,190 @@
'use client';
import { useRef } from 'react';
import Button from '@/ui/Button';
import { Button } from '@/ui/Button';
import { Surface } from '@/ui/Surface';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { MessageSquare, Lightbulb, Users, Code, LucideIcon } from 'lucide-react';
import { DiscordIcon } from '@/ui/icons/DiscordIcon';
export default function DiscordCTA() {
export function DiscordCTA() {
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';
return (
<section id="community" className="relative py-4 md:py-12 lg:py-16 bg-gradient-to-b from-deep-graphite to-iron-gray">
<div className="max-w-4xl mx-auto px-2 md:px-3 lg:px-4">
<div
className="relative rounded-xl bg-gradient-to-br from-iron-gray via-deep-graphite to-iron-gray p-3 md:p-6 lg:p-10 border border-charcoal-outline shadow-[0_0_80px_rgba(88,101,242,0.15)]"
style={{
opacity: 1,
transform: 'scale(1)'
}}
<Surface
as="section"
variant="discord"
padding={4}
position="relative"
py={{ base: 4, md: 12, lg: 16 }}
>
<Box maxWidth="896px" mx="auto" px={2}>
<Surface
variant="discord-inner"
padding={3}
border
rounded="xl"
position="relative"
shadow="discord"
>
{/* Discord brand accent */}
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-[#5865F2]/60 to-transparent" />
<Box
position="absolute"
top={0}
left={0}
right={0}
height="1"
backgroundColor="[#5865F2]"
opacity={0.6}
className="bg-gradient-to-r from-transparent via-[#5865F2]/60 to-transparent"
/>
<div className="text-center space-y-2 md:space-y-4 lg:space-y-6">
<Stack align="center" gap={6} center>
{/* Header */}
<div className="space-y-1.5 md:space-y-3 lg:space-y-4">
<div
className="inline-flex items-center justify-center w-10 h-10 md:w-14 md:h-14 lg:w-18 lg:h-18 rounded-full bg-[#5865F2]/20 border border-[#5865F2]/30 mb-1.5 md:mb-3"
style={{
opacity: 1,
transform: 'scale(1) rotate(0deg)'
}}
<Stack align="center" gap={4}>
<Box
display="flex"
center
rounded="full"
w={{ base: "10", md: "14", lg: "18" }}
h={{ base: "10", md: "14", lg: "18" }}
backgroundColor="[#5865F2]"
opacity={0.2}
border
borderColor="[#5865F2]"
>
<svg className="w-6 h-6 md:w-8 md:h-8 text-[#5865F2]" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
</div>
<DiscordIcon color="text-[#5865F2]" size={32} />
</Box>
<div
style={{
opacity: 1,
transform: 'translateY(0)'
}}
>
<h2 className="text-lg md:text-2xl lg:text-3xl font-semibold text-white">
<Stack gap={2}>
<Text as="h2" size="2xl" weight="semibold" color="text-white">
Join us on Discord
</h2>
<div className="w-16 md:w-24 lg:w-32 h-0.5 md:h-1 bg-gradient-to-r from-[#5865F2] to-[#7289DA] mx-auto rounded-full" />
</div>
</div>
</Text>
<Box
mx="auto"
rounded="full"
w={{ base: "16", md: "24", lg: "32" }}
h={{ base: "0.5", md: "1" }}
backgroundColor="[#5865F2]"
className="bg-gradient-to-r from-[#5865F2] to-[#7289DA]"
/>
</Stack>
</Stack>
{/* Personal message */}
<div
className="max-w-2xl mx-auto space-y-1.5 md:space-y-3 lg:space-y-4"
style={{
opacity: 1,
transform: 'translateY(0)'
}}
>
<p className="text-xs md:text-sm lg:text-base text-gray-300 font-light leading-relaxed">
GridPilot is a <strong className="text-white">solo developer project</strong>, and I'm building it because I got tired of the chaos in league racing.
</p>
<p className="text-xs md:text-sm text-gray-400 font-light leading-relaxed">
This is <strong className="text-gray-300">early days</strong>, and I need your help. Join the Discord to:
</p>
</div>
<Box maxWidth="672px" mx="auto">
<Stack gap={3}>
<Text size="sm" color="text-gray-300" weight="normal" leading="relaxed">
GridPilot is a <Text weight="bold" color="text-white">solo developer project</Text>, and I'm building it because I got tired of the chaos in league racing.
</Text>
<Text size="sm" color="text-gray-400" weight="normal" leading="relaxed">
This is <Text weight="bold" color="text-gray-300">early days</Text>, and I need your help. Join the Discord to:
</Text>
</Stack>
</Box>
{/* Benefits grid */}
<div
className="grid md:grid-cols-2 gap-1.5 md:gap-2 lg:gap-3 max-w-2xl mx-auto mt-2 md:mt-4 lg:mt-6"
style={{
opacity: 1,
transform: 'scale(1)'
}}
<Box
maxWidth="2xl"
mx="auto"
mt={4}
>
<div className="flex items-start gap-1.5 md:gap-2.5 text-left p-2 md:p-3.5 rounded-lg bg-iron-gray/50 border border-charcoal-outline hover:border-[#5865F2]/30 transition-colors">
<div className="flex-shrink-0 w-5 h-5 md:w-6 md:h-6 rounded-lg bg-[#5865F2]/20 border border-[#5865F2]/30 flex items-center justify-center mt-0.5">
<svg className="w-3 h-3 md:w-4 md:h-4 text-[#5865F2]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</div>
<div>
<h3 className="text-[10px] md:text-xs text-white font-medium mb-0.5">Share your pain points</h3>
<p className="text-[9px] md:text-xs text-gray-400 leading-relaxed">Tell me what frustrates you about league racing today</p>
</div>
</div>
<Grid cols={2} gap={3} className="md:grid-cols-2">
<BenefitItem
icon={MessageSquare}
title="Share your pain points"
description="Tell me what frustrates you about league racing today"
/>
<BenefitItem
icon={Lightbulb}
title="Shape the product"
description="Your ideas will directly influence what gets built"
/>
<BenefitItem
icon={Users}
title="Be part of the community"
description="Connect with other league racers who get it"
/>
<BenefitItem
icon={Code}
title="Get early access"
description="Test features first and help iron out the rough edges"
/>
</Grid>
</Box>
<div className="flex items-start gap-1.5 md:gap-2.5 text-left p-2 md:p-3.5 rounded-lg bg-iron-gray/50 border border-charcoal-outline hover:border-[#5865F2]/30 transition-colors">
<div className="flex-shrink-0 w-5 h-5 md:w-6 md:h-6 rounded-lg bg-[#5865F2]/20 border border-[#5865F2]/30 flex items-center justify-center mt-0.5">
<svg className="w-3 h-3 md:w-4 md:h-4 text-[#5865F2]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<div>
<h3 className="text-[10px] md:text-xs text-white font-medium mb-0.5">Shape the product</h3>
<p className="text-[9px] md:text-xs text-gray-400 leading-relaxed">Your ideas will directly influence what gets built</p>
</div>
</div>
<div className="flex items-start gap-1.5 md:gap-2.5 text-left p-2 md:p-3.5 rounded-lg bg-iron-gray/50 border border-charcoal-outline hover:border-[#5865F2]/30 transition-colors">
<div className="flex-shrink-0 w-5 h-5 md:w-6 md:h-6 rounded-lg bg-[#5865F2]/20 border border-[#5865F2]/30 flex items-center justify-center mt-0.5">
<svg className="w-3 h-3 md:w-4 md:h-4 text-[#5865F2]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div>
<h3 className="text-[10px] md:text-xs text-white font-medium mb-0.5">Be part of the community</h3>
<p className="text-[9px] md:text-xs text-gray-400 leading-relaxed">Connect with other league racers who get it</p>
</div>
</div>
<div className="flex items-start gap-1.5 md:gap-2.5 text-left p-2 md:p-3.5 rounded-lg bg-iron-gray/50 border border-charcoal-outline hover:border-[#5865F2]/30 transition-colors">
<div className="flex-shrink-0 w-5 h-5 md:w-6 md:h-6 rounded-lg bg-[#5865F2]/20 border border-[#5865F2]/30 flex items-center justify-center mt-0.5">
<svg className="w-3 h-3 md:w-4 md:h-4 text-[#5865F2]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</div>
<div>
<h3 className="text-[10px] md:text-xs text-white font-medium mb-0.5">Get early access</h3>
<p className="text-[9px] md:text-xs text-gray-400 leading-relaxed">Test features first and help iron out the rough edges</p>
</div>
</div>
</div>
{/* CTA Button - Matching Hero style */}
<div
className="pt-2 md:pt-4 lg:pt-6"
style={{
opacity: 1,
transform: 'translateY(0) scale(1)'
}}
>
<a
{/* CTA Button */}
<Stack gap={3} pt={4}>
<Button
as="a"
href={discordUrl}
target="_blank"
rel="noopener noreferrer"
className="group relative inline-flex items-center justify-center gap-3 px-8 py-4 min-h-[44px] min-w-[44px] bg-[#5865F2] hover:bg-[#4752C4] text-white font-semibold text-base sm:text-lg rounded-lg transition-all duration-300 hover:scale-105 hover:-translate-y-0.5 shadow-[0_0_20px_rgba(88,101,242,0.3)] hover:shadow-[0_0_30px_rgba(88,101,242,0.6)] active:scale-95"
aria-label="Join our Discord community"
variant="discord"
size="lg"
icon={<DiscordIcon size={28} />}
>
{/* Discord Logo SVG */}
<svg
className="w-7 h-7 transition-transform duration-300 group-hover:scale-110"
viewBox="0 0 71 55"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0)">
<path
d="M60.1045 4.8978C55.5792 2.8214 50.7265 1.2916 45.6527 0.41542C45.5603 0.39851 45.468 0.440769 45.4204 0.525289C44.7963 1.6353 44.105 3.0834 43.6209 4.2216C38.1637 3.4046 32.7345 3.4046 27.3892 4.2216C26.905 3.0581 26.1886 1.6353 25.5617 0.525289C25.5141 0.443589 25.4218 0.40133 25.3294 0.41542C20.2584 1.2888 15.4057 2.8186 10.8776 4.8978C10.8384 4.9147 10.8048 4.9429 10.7825 4.9795C1.57795 18.7309 -0.943561 32.1443 0.293408 45.3914C0.299005 45.4562 0.335386 45.5182 0.385761 45.5576C6.45866 50.0174 12.3413 52.7249 18.1147 54.5195C18.2071 54.5477 18.305 54.5139 18.3638 54.4378C19.7295 52.5728 20.9469 50.6063 21.9907 48.5383C22.0523 48.4172 21.9935 48.2735 21.8676 48.2256C19.9366 47.4931 18.0979 46.6 16.3292 45.5858C16.1893 45.5041 16.1781 45.304 16.3068 45.2082C16.679 44.9293 17.0513 44.6391 17.4067 44.3461C17.471 44.2926 17.5606 44.2813 17.6362 44.3151C29.2558 49.6202 41.8354 49.6202 53.3179 44.3151C53.3935 44.2785 53.4831 44.2898 53.5502 44.3433C53.9057 44.6363 54.2779 44.9293 54.6529 45.2082C54.7816 45.304 54.7732 45.5041 54.6333 45.5858C52.8646 46.6197 51.0259 47.4931 49.0921 48.2228C48.9662 48.2707 48.9102 48.4172 48.9718 48.5383C50.038 50.6034 51.2554 52.5699 52.5959 54.435C52.6519 54.5139 52.7526 54.5477 52.845 54.5195C58.6464 52.7249 64.529 50.0174 70.6019 45.5576C70.6551 45.5182 70.6887 45.459 70.6943 45.3942C72.1747 30.0791 68.2147 16.7757 60.1968 4.9823C60.1772 4.9429 60.1437 4.9147 60.1045 4.8978ZM23.7259 37.3253C20.2276 37.3253 17.3451 34.1136 17.3451 30.1693C17.3451 26.225 20.1717 23.0133 23.7259 23.0133C27.308 23.0133 30.1626 26.2532 30.1066 30.1693C30.1066 34.1136 27.28 37.3253 23.7259 37.3253ZM47.3178 37.3253C43.8196 37.3253 40.9371 34.1136 40.9371 30.1693C40.9371 26.225 43.7636 23.0133 47.3178 23.0133C50.9 23.0133 53.7545 26.2532 53.6986 30.1693C53.6986 34.1136 50.9 37.3253 47.3178 37.3253Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0">
<rect width="71" height="55" fill="white"/>
</clipPath>
</defs>
</svg>
<span>Join us on Discord</span>
</a>
Join us on Discord
</Button>
{/* Early alpha hint */}
<p className="mt-3 text-xs sm:text-sm text-primary-blue/80 font-light">
<Text size="xs" color="text-primary-blue" weight="light">
💡 Get a link to our early alpha view in the Discord
</p>
</Text>
{!process.env.NEXT_PUBLIC_DISCORD_URL && (
<p className="mt-4 text-xs text-gray-500">
<Text size="xs" color="text-gray-500">
Note: Configure NEXT_PUBLIC_DISCORD_URL in your environment variables
</p>
</Text>
)}
</div>
</Stack>
{/* Footer note */}
<p
className="text-[9px] md:text-xs text-gray-500 font-light leading-relaxed max-w-xl mx-auto pt-2 md:pt-4"
style={{
opacity: 1
}}
>
This is a community effort. Every voice matters. Let's build something that actually works for league racing.
</p>
</div>
</div>
</div>
</section>
<Box maxWidth="xl" mx="auto" pt={4}>
<Text size="xs" color="text-gray-500" weight="light" leading="relaxed" align="center" block>
This is a community effort. Every voice matters. Let's build something that actually works for league racing.
</Text>
</Box>
</Stack>
</Surface>
</Box>
</Surface>
);
}
}
function BenefitItem({ icon, title, description }: { icon: LucideIcon, title: string, description: string }) {
return (
<Surface
variant="muted"
border
padding={3}
rounded="lg"
display="flex"
gap={3}
className="items-start hover:border-[#5865F2]/30 transition-all"
>
<Box
display="flex"
center
rounded="lg"
flexShrink={0}
w="6"
h="6"
backgroundColor="[#5865F2]"
opacity={0.2}
border
borderColor="[#5865F2]"
mt={0.5}
>
<Icon icon={icon} size={4} color="text-[#5865F2]" />
</Box>
<Stack gap={0.5}>
<Text size="xs" weight="medium" color="text-white">{title}</Text>
<Text size="xs" color="text-gray-400" leading="relaxed">{description}</Text>
</Stack>
</Surface>
);
}
import { Grid } from '@/ui/Grid';

View File

@@ -1,220 +0,0 @@
'use client';
import { useState, FormEvent } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useInject } from '@/lib/di/hooks/useInject';
import { LANDING_SERVICE_TOKEN } from '@/lib/di/tokens';
type FeedbackState =
| { type: 'idle' }
| { type: 'loading' }
| { type: 'success'; message: string }
| { type: 'error'; message: string; canRetry?: boolean; retryAfter?: number }
| { type: 'info'; message: string };
export default function EmailCapture() {
const [email, setEmail] = useState('');
const [feedback, setFeedback] = useState<FeedbackState>({ type: 'idle' });
const landingService = useInject(LANDING_SERVICE_TOKEN);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!email) {
setFeedback({ type: 'error', message: "That email doesn't look right." });
return;
}
setFeedback({ type: 'loading' });
try {
const result = await landingService.signup(email);
if (result.isOk()) {
setFeedback({ type: 'success', message: 'Thanks! You\'re on the list.' });
setEmail('');
setTimeout(() => setFeedback({ type: 'idle' }), 5000);
} else {
const error = result.getError();
if (error.type === 'notImplemented') {
setFeedback({ type: 'info', message: 'Signup feature coming soon!' });
setTimeout(() => setFeedback({ type: 'idle' }), 4000);
} else {
setFeedback({
type: 'error',
message: error.message || 'Something broke. Try again?',
canRetry: true
});
}
}
} catch (error) {
setFeedback({
type: 'error',
message: 'Something broke. Try again?',
canRetry: true
});
console.error('Signup error:', error);
}
};
const getMessageColor = () => {
if (feedback.type === 'success') return 'text-performance-green';
if (feedback.type === 'info') return 'text-gray-400';
if (feedback.type === 'error' && feedback.retryAfter) return 'text-warning-amber';
if (feedback.type === 'error') return 'text-red-400';
return '';
};
const getGlowColor = () => {
if (feedback.type === 'success') return 'shadow-[0_0_80px_rgba(111,227,122,0.15)]';
if (feedback.type === 'info') return 'shadow-[0_0_80px_rgba(34,38,42,0.15)]';
if (feedback.type === 'error' && feedback.retryAfter) return 'shadow-[0_0_80px_rgba(255,197,86,0.15)]';
if (feedback.type === 'error') return 'shadow-[0_0_80px_rgba(248,113,113,0.15)]';
return 'shadow-[0_0_80px_rgba(25,140,255,0.15)]';
};
return (
<section id="early-access" className="relative py-24 bg-gradient-to-b from-deep-graphite to-iron-gray">
<div className="max-w-2xl mx-auto px-6">
<AnimatePresence mode="wait">
{feedback.type === 'success' ? (
<motion.div
key="success"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.25 }}
className={`relative rounded-xl bg-gradient-to-br from-iron-gray via-deep-graphite to-iron-gray p-12 text-center border border-charcoal-outline ${getGlowColor()}`}
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.15, type: 'spring', stiffness: 200, damping: 15 }}
className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-performance-green/20 mb-6"
>
<svg className="w-8 h-8 text-performance-green" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</motion.div>
<h2 className="text-3xl font-semibold text-white mb-4">{feedback.message}</h2>
<p className="text-base text-gray-400 font-light">
I'll send updates as I build. Zero spam, zero BS.
</p>
</motion.div>
) : (
<motion.div
key="form"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={`relative rounded-xl bg-gradient-to-br from-iron-gray via-deep-graphite to-iron-gray p-8 md:p-16 border border-charcoal-outline transition-shadow duration-300 ${getGlowColor()}`}
>
<div className="text-center mb-10">
<h2 className="text-3xl md:text-4xl font-semibold text-white mb-3">
Let me know if this resonates
</h2>
<div className="w-32 h-1 bg-gradient-to-r from-primary-blue to-neon-aqua mx-auto mb-6 rounded-full" />
<p className="text-lg text-gray-400 font-light max-w-lg mx-auto mb-4">
I'm building GridPilot because I got tired of the chaos. If this resonates with you, drop your email.
</p>
<p className="text-base text-gray-400/80 font-light max-w-lg mx-auto">
It means someone out there cares about the same problems. That keeps me going.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<input
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
if (feedback.type !== 'loading') {
setFeedback({ type: 'idle' });
}
}}
placeholder="your@email.com"
disabled={feedback.type === 'loading'}
className={`w-full px-6 py-4 rounded-lg bg-iron-gray text-white placeholder-gray-500 border transition-all duration-150 ${
feedback.type === 'error' && !feedback.retryAfter
? 'border-red-500 focus:ring-2 focus:ring-red-500'
: feedback.type === 'error' && feedback.retryAfter
? 'border-warning-amber focus:ring-2 focus:ring-warning-amber/50'
: 'border-charcoal-outline focus:border-neon-aqua focus:ring-2 focus:ring-neon-aqua/50'
} hover:scale-[1.01] disabled:opacity-50 disabled:cursor-not-allowed`}
aria-label="Email address"
/>
<AnimatePresence>
{(feedback.type === 'error' || feedback.type === 'info') && (
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className={`mt-2 text-sm ${getMessageColor()}`}
>
{feedback.message}
{feedback.type === 'error' && feedback.retryAfter && (
<span className="block mt-1 text-xs text-gray-500">
Retry in {feedback.retryAfter}s
</span>
)}
</motion.p>
)}
</AnimatePresence>
</div>
<motion.button
type="submit"
disabled={feedback.type === 'loading'}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ duration: 0.15 }}
className="px-8 py-4 rounded-full bg-gradient-to-r from-primary-blue to-neon-aqua text-white font-semibold transition-all duration-150 hover:shadow-glow-strong active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 whitespace-nowrap"
>
{feedback.type === 'loading' ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Joining...
</span>
) : (
'Count me in'
)}
</motion.button>
</div>
</form>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="mt-8 flex flex-col sm:flex-row gap-4 justify-center text-sm text-gray-400 font-light"
>
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-performance-green" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span>I'll send updates as I build</span>
</div>
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-performance-green" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span>You can tell me what matters most</span>
</div>
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-performance-green" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span>Zero spam, zero BS</span>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
</section>
);
}

View File

@@ -1,6 +1,8 @@
'use client';
import Link from 'next/link';
import React from 'react';
import { Link } from '@/ui/Link';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
export type BreadcrumbItem = {
label: string;
@@ -12,7 +14,7 @@ interface BreadcrumbsProps {
className?: string;
}
export default function Breadcrumbs({ items, className }: BreadcrumbsProps) {
export function Breadcrumbs({ items }: BreadcrumbsProps) {
if (!items || items.length === 0) {
return null;
}
@@ -20,34 +22,31 @@ export default function Breadcrumbs({ items, className }: BreadcrumbsProps) {
const lastIndex = items.length - 1;
return (
<nav
aria-label="Breadcrumb"
className={className ?? 'text-sm text-gray-400 mb-4'}
>
<ol className="flex items-center gap-2 flex-wrap">
<Box as="nav" aria-label="Breadcrumb" mb={4}>
<Stack direction="row" align="center" gap={2} wrap>
{items.map((item, index) => {
const isLast = index === lastIndex;
const content = item.href && !isLast ? (
<Link
href={item.href}
className="hover:text-primary-blue transition-colors"
variant="ghost"
>
{item.label}
</Link>
) : (
<span className={isLast ? 'text-white' : ''}>{item.label}</span>
<Text color={isLast ? 'text-white' : 'text-gray-400'}>{item.label}</Text>
);
return (
<li key={`${item.label}-${index}`} className="flex items-center gap-2">
<Stack key={`${item.label}-${index}`} direction="row" align="center" gap={2}>
{index > 0 && (
<span className="text-gray-600">/</span>
<Text color="text-gray-600">/</Text>
)}
{content}
</li>
</Stack>
);
})}
</ol>
</nav>
</Stack>
</Box>
);
}

View File

@@ -1,34 +0,0 @@
import Card from '@/ui/Card';
interface BonusPointsCardProps {
bonusSummary: string[];
}
export function BonusPointsCard({ bonusSummary }: BonusPointsCardProps) {
if (!bonusSummary || bonusSummary.length === 0) {
return null;
}
return (
<Card>
<div className="mb-4">
<h3 className="text-lg font-semibold text-white">Bonus Points</h3>
<p className="text-sm text-gray-400 mt-1">Additional points for special achievements</p>
</div>
<div className="space-y-3">
{bonusSummary.map((bonus, idx) => (
<div
key={idx}
className="flex items-center gap-4 p-4 bg-deep-graphite rounded-lg border border-charcoal-outline transition-colors hover:border-primary-blue/30"
>
<div className="w-10 h-10 rounded-full bg-performance-green/10 border border-performance-green/20 flex items-center justify-center shrink-0">
<span className="text-performance-green text-lg font-bold">+</span>
</div>
<p className="text-sm text-gray-300 flex-1">{bonus}</p>
</div>
))}
</div>
</Card>
);
}

View File

@@ -1,103 +0,0 @@
import Card from '@/ui/Card';
import type { LeagueScoringChampionshipViewModel } from '@/lib/view-models/LeagueScoringChampionshipViewModel';
type PointsPreviewRow = {
sessionType: string;
position: number;
points: number;
};
interface ChampionshipCardProps {
championship: LeagueScoringChampionshipViewModel;
}
export function ChampionshipCard({ championship }: ChampionshipCardProps) {
const pointsPreview = (championship.pointsPreview as unknown as PointsPreviewRow[]) ?? [];
const dropPolicyDescription = (championship as unknown as { dropPolicyDescription?: string }).dropPolicyDescription ?? '';
const getTypeLabel = (type: string): string => {
switch (type) {
case 'driver':
return 'Driver Championship';
case 'team':
return 'Team Championship';
case 'nations':
return 'Nations Championship';
case 'trophy':
return 'Trophy Championship';
default:
return 'Championship';
}
};
const getTypeBadgeStyle = (type: string): string => {
switch (type) {
case 'driver':
return 'bg-primary-blue/10 text-primary-blue border-primary-blue/20';
case 'team':
return 'bg-purple-500/10 text-purple-400 border-purple-500/20';
case 'nations':
return 'bg-performance-green/10 text-performance-green border-performance-green/20';
case 'trophy':
return 'bg-warning-amber/10 text-warning-amber border-warning-amber/20';
default:
return 'bg-gray-500/10 text-gray-400 border-gray-500/20';
}
};
return (
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-white">{championship.name}</h3>
<span className={`inline-block mt-2 px-2.5 py-1 text-xs font-medium rounded border ${getTypeBadgeStyle(championship.type)}`}>
{getTypeLabel(championship.type)}
</span>
</div>
</div>
<div className="space-y-4">
{/* Session Types */}
{championship.sessionTypes.length > 0 && (
<div>
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Scored Sessions</h4>
<div className="flex flex-wrap gap-2">
{championship.sessionTypes.map((session, idx) => (
<span
key={idx}
className="px-3 py-1.5 rounded bg-deep-graphite text-gray-300 text-sm font-medium border border-charcoal-outline capitalize"
>
{session}
</span>
))}
</div>
</div>
)}
{/* Points Preview */}
{pointsPreview.length > 0 && (
<div>
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Points Distribution</h4>
<div className="bg-deep-graphite rounded-lg border border-charcoal-outline p-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3">
{pointsPreview.slice(0, 6).map((preview, idx) => (
<div key={idx} className="text-center">
<div className="text-xs text-gray-500 mb-1">P{preview.position}</div>
<div className="text-lg font-bold text-white tabular-nums">{preview.points}</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Drop Policy */}
<div className="p-4 bg-deep-graphite rounded-lg border border-charcoal-outline">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Drop Policy</span>
</div>
<p className="text-sm text-gray-300">{dropPolicyDescription}</p>
</div>
</div>
</Card>
);
}

View File

@@ -1,192 +0,0 @@
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import Input from '../ui/Input';
import Button from '../ui/Button';
import { useCreateLeague } from "@/lib/hooks/league/useCreateLeague";
import { useAuth } from '@/lib/auth/AuthContext';
import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
interface FormErrors {
name?: string;
description?: string;
pointsSystem?: string;
sessionDuration?: string;
submit?: string;
}
export default function CreateLeagueForm() {
const router = useRouter();
const [errors, setErrors] = useState<FormErrors>({});
const [formData, setFormData] = useState({
name: '',
description: '',
pointsSystem: 'f1-2024' as 'f1-2024' | 'indycar',
sessionDuration: 60
});
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
if (!formData.name.trim()) {
newErrors.name = 'Name is required';
} else if (formData.name.length > 100) {
newErrors.name = 'Name must be 100 characters or less';
}
if (!formData.description.trim()) {
newErrors.description = 'Description is required';
} else if (formData.description.length > 500) {
newErrors.description = 'Description must be 500 characters or less';
}
if (formData.sessionDuration < 1 || formData.sessionDuration > 240) {
newErrors.sessionDuration = 'Session duration must be between 1 and 240 minutes';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const { session } = useAuth();
const driverService = useInject(DRIVER_SERVICE_TOKEN);
const createLeagueMutation = useCreateLeague();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (createLeagueMutation.isPending) return;
if (!validateForm()) return;
if (!session?.user.userId) {
setErrors({ submit: 'You must be logged in to create a league' });
return;
}
try {
// Get current driver
const currentDriver = await driverService.getDriverProfile(session.user.userId);
if (!currentDriver) {
setErrors({ submit: 'No driver profile found. Please create a profile first.' });
return;
}
// Create league using the league service
const input = {
name: formData.name.trim(),
description: formData.description.trim(),
visibility: 'public' as const,
ownerId: session.user.userId,
};
const result = await createLeagueMutation.mutateAsync(input);
router.push(`/leagues/${result.leagueId}`);
router.refresh();
} catch (error) {
setErrors({
submit: error instanceof Error ? error.message : 'Failed to create league'
});
}
};
return (
<>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
League Name *
</label>
<Input
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
error={!!errors.name}
errorMessage={errors.name}
placeholder="European GT Championship"
maxLength={100}
disabled={createLeagueMutation.isPending}
/>
<p className="mt-1 text-xs text-gray-500 text-right">
{formData.name.length}/100
</p>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-300 mb-2">
Description *
</label>
<textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Weekly GT3 racing with professional drivers"
maxLength={500}
rows={4}
disabled={createLeagueMutation.isPending}
className="block w-full rounded-md border-0 px-4 py-3 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 sm:leading-6 resize-none"
/>
<p className="mt-1 text-xs text-gray-500 text-right">
{formData.description.length}/500
</p>
{errors.description && (
<p className="mt-2 text-sm text-warning-amber">{errors.description}</p>
)}
</div>
<div>
<label htmlFor="pointsSystem" className="block text-sm font-medium text-gray-300 mb-2">
Points System *
</label>
<select
id="pointsSystem"
value={formData.pointsSystem}
onChange={(e) => setFormData({ ...formData, pointsSystem: e.target.value as 'f1-2024' | 'indycar' })}
disabled={createLeagueMutation.isPending}
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6"
>
<option value="f1-2024">F1 2024</option>
<option value="indycar">IndyCar</option>
</select>
</div>
<div>
<label htmlFor="sessionDuration" className="block text-sm font-medium text-gray-300 mb-2">
Session Duration (minutes) *
</label>
<Input
id="sessionDuration"
type="number"
value={formData.sessionDuration}
onChange={(e) => setFormData({ ...formData, sessionDuration: parseInt(e.target.value) || 60 })}
error={!!errors.sessionDuration}
errorMessage={errors.sessionDuration}
min={1}
max={240}
disabled={createLeagueMutation.isPending}
/>
</div>
{errors.submit && (
<div className="rounded-md bg-warning-amber/10 p-4 border border-warning-amber/20">
<p className="text-sm text-warning-amber">{errors.submit}</p>
</div>
)}
<Button
type="submit"
variant="primary"
disabled={createLeagueMutation.isPending}
className="w-full"
>
{createLeagueMutation.isPending ? 'Creating League...' : 'Create League'}
</Button>
</form>
</>
);
}

View File

@@ -1,31 +0,0 @@
import Card from '@/ui/Card';
interface DropRulesExplanationProps {
dropPolicyDescription: string;
}
export function DropRulesExplanation({ dropPolicyDescription }: DropRulesExplanationProps) {
// Don't show if all results count
const hasDropRules = !dropPolicyDescription.toLowerCase().includes('all results count');
if (!hasDropRules) {
return null;
}
return (
<Card>
<div className="mb-4">
<h3 className="text-lg font-semibold text-white">Drop Score Rules</h3>
<p className="text-sm text-gray-400 mt-1">How your worst results are handled</p>
</div>
<div className="p-4 bg-deep-graphite rounded-lg border border-charcoal-outline">
<p className="text-sm text-gray-300">{dropPolicyDescription}</p>
</div>
<p className="mt-4 text-xs text-gray-500">
Drop rules are applied automatically when calculating championship standings. Focus on racing the system handles the rest.
</p>
</Card>
);
}

View File

@@ -1,13 +1,17 @@
import { Trophy, Sparkles, Search } from 'lucide-react';
import Heading from '@/ui/Heading';
import Button from '@/ui/Button';
import Card from '@/ui/Card';
import { Trophy, Sparkles, LucideIcon } from 'lucide-react';
import { Heading } from '@/ui/Heading';
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 { Icon } from '@/ui/Icon';
interface EmptyStateProps {
title: string;
description: string;
icon?: React.ElementType;
actionIcon?: React.ElementType;
icon?: LucideIcon;
actionIcon?: LucideIcon;
actionLabel?: string;
onAction?: () => void;
children?: React.ReactNode;
@@ -17,37 +21,43 @@ interface EmptyStateProps {
export function EmptyState({
title,
description,
icon: Icon = Trophy,
actionIcon: ActionIcon = Sparkles,
icon = Trophy,
actionIcon = Sparkles,
actionLabel,
onAction,
children,
className,
}: EmptyStateProps) {
return (
<Card className={`text-center py-16 ${className || ''}`}>
<div className="max-w-md mx-auto">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-2xl bg-primary-blue/10 border border-primary-blue/20 mb-6">
<Icon className="w-8 h-8 text-primary-blue" />
</div>
<Heading level={2} className="text-2xl mb-3">
{title}
</Heading>
<p className="text-gray-400 mb-8">
{description}
</p>
{children}
{actionLabel && onAction && (
<Button
variant="primary"
onClick={onAction}
className="flex items-center gap-2 mx-auto"
>
<ActionIcon className="w-4 h-4" />
{actionLabel}
</Button>
)}
</div>
<Card className={className}>
<Box textAlign="center" py={16}>
<Box maxWidth="md" mx="auto">
<Box height={16} width={16} mx="auto" display="flex" center rounded="2xl" backgroundColor="primary-blue" opacity={0.1} border borderColor="primary-blue" mb={6}>
<Icon icon={icon} size={8} color="text-primary-blue" />
</Box>
<Box mb={3}>
<Heading level={2}>
{title}
</Heading>
</Box>
<Box mb={8}>
<Text color="text-gray-400">
{description}
</Text>
</Box>
{children}
{actionLabel && onAction && (
<Button
variant="primary"
onClick={onAction}
icon={<Icon icon={actionIcon} size={4} />}
className="mx-auto"
>
{actionLabel}
</Button>
)}
</Box>
</Box>
</Card>
);
}
}

View File

@@ -1,85 +0,0 @@
import { Trophy, Plus } from 'lucide-react';
import Heading from '@/ui/Heading';
import Button from '@/ui/Button';
interface StatItem {
value: number;
label: string;
color: string;
animate?: boolean;
}
interface HeroSectionProps {
icon?: React.ElementType;
title: string;
description: string;
stats?: StatItem[];
ctaLabel?: string;
ctaDescription?: string;
onCtaClick?: () => void;
className?: string;
}
export function HeroSection({
icon: Icon = Trophy,
title,
description,
stats = [],
ctaLabel = "Create League",
ctaDescription = "Set up your own racing series",
onCtaClick,
className,
}: HeroSectionProps) {
return (
<div className={`relative mb-10 py-10 px-8 rounded-2xl bg-gradient-to-br from-iron-gray/80 via-deep-graphite to-iron-gray/60 border border-charcoal-outline/50 overflow-hidden ${className || ''}`}>
{/* Background decoration */}
<div className="absolute top-0 right-0 w-96 h-96 bg-primary-blue/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-64 h-64 bg-neon-aqua/5 rounded-full blur-3xl" />
<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">
<Icon className="w-6 h-6 text-primary-blue" />
</div>
<Heading level={1} className="text-3xl lg:text-4xl">
{title}
</Heading>
</div>
<p className="text-gray-400 text-lg leading-relaxed mb-6">
{description}
</p>
{/* Stats */}
{stats.length > 0 && (
<div className="flex flex-wrap gap-6">
{stats.map((stat, index) => (
<div key={index} className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${stat.color} ${stat.animate ? 'animate-pulse' : ''}`} />
<span className="text-sm text-gray-400">
<span className="text-white font-semibold">{stat.value}</span> {stat.label}
</span>
</div>
))}
</div>
)}
</div>
{/* CTA */}
{onCtaClick && (
<div className="flex flex-col gap-4">
<Button
variant="primary"
onClick={onCtaClick}
className="flex items-center gap-2 px-6 py-3"
>
<Plus className="w-5 h-5" />
<span>{ctaLabel}</span>
</Button>
<p className="text-xs text-gray-500 text-center">{ctaDescription}</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -2,8 +2,16 @@
import React from 'react';
import { FileText, Gamepad2, AlertCircle, Check } from 'lucide-react';
import Input from '@/ui/Input';
import { Input } from '@/ui/Input';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
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 { Grid } from '@/ui/Grid';
import { Surface } from '@/ui/Surface';
import { Button } from '@/ui/Button';
interface LeagueBasicsSectionProps {
form: LeagueConfigFormModel;
@@ -36,126 +44,135 @@ export function LeagueBasicsSection({
};
return (
<div className="space-y-8">
<Stack gap={8}>
{/* Emotional header for the step */}
<div className="text-center pb-2">
<h3 className="text-lg font-semibold text-white mb-2">
Every great championship starts with a name
</h3>
<p className="text-sm text-gray-400 max-w-lg mx-auto">
This is where legends begin. Give your league an identity that drivers will remember.
</p>
</div>
<Box textAlign="center" pb={2}>
<Box mb={2}>
<Heading level={3}>
Every great championship starts with a name
</Heading>
</Box>
<Box maxWidth="lg" mx="auto">
<Text size="sm" color="text-gray-400">
This is where legends begin. Give your league an identity that drivers will remember.
</Text>
</Box>
</Box>
{/* League name */}
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
<FileText className="w-4 h-4 text-primary-blue" />
League name *
</label>
<Stack gap={3}>
<Text as="label" size="sm" weight="medium" color="text-gray-300">
<Stack direction="row" align="center" gap={2}>
<Icon icon={FileText} size={4} color="text-primary-blue" />
League name *
</Stack>
</Text>
<Input
value={basics.name}
onChange={(e) => updateBasics({ name: e.target.value })}
placeholder="e.g., GridPilot Sprint Series"
error={!!errors?.name}
variant={errors?.name ? 'error' : 'default'}
errorMessage={errors?.name}
disabled={disabled}
autoFocus
/>
<div className="space-y-2">
<p className="text-xs text-gray-500">
<Stack gap={2}>
<Text size="xs" color="text-gray-500">
Make it memorable this is what drivers will see first
</p>
<div className="flex flex-wrap gap-2">
<span className="text-xs text-gray-500">Try:</span>
<button
type="button"
onClick={() => updateBasics({ name: 'Sunday Showdown Series' })}
className="text-xs px-2 py-0.5 rounded-full bg-primary-blue/10 text-primary-blue hover:bg-primary-blue/20 transition-colors"
disabled={disabled}
>
Sunday Showdown Series
</button>
<button
type="button"
onClick={() => updateBasics({ name: 'Midnight Endurance League' })}
className="text-xs px-2 py-0.5 rounded-full bg-primary-blue/10 text-primary-blue hover:bg-primary-blue/20 transition-colors"
disabled={disabled}
>
Midnight Endurance League
</button>
<button
type="button"
onClick={() => updateBasics({ name: 'GT Masters Championship' })}
className="text-xs px-2 py-0.5 rounded-full bg-primary-blue/10 text-primary-blue hover:bg-primary-blue/20 transition-colors"
disabled={disabled}
>
GT Masters Championship
</button>
</div>
</div>
</div>
</Text>
<Stack direction="row" wrap gap={2}>
<Text size="xs" color="text-gray-500">Try:</Text>
{[
'Sunday Showdown Series',
'Midnight Endurance League',
'GT Masters Championship'
].map(name => (
<Button
key={name}
type="button"
onClick={() => updateBasics({ name })}
variant="secondary"
size="sm"
className="h-auto py-0.5 px-2 rounded-full text-xs"
disabled={disabled}
>
{name}
</Button>
))}
</Stack>
</Stack>
</Stack>
{/* Description - Now Required */}
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
<FileText className="w-4 h-4 text-primary-blue" />
Tell your story *
</label>
<textarea
value={basics.description ?? ''}
onChange={(e) =>
updateBasics({
description: e.target.value,
})
}
rows={4}
disabled={disabled}
className={`block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue text-sm disabled:opacity-60 disabled:cursor-not-allowed transition-all duration-150 ${
errors?.description ? 'ring-warning-amber' : 'ring-charcoal-outline'
}`}
placeholder="What makes your league special? Tell drivers what to expect..."
/>
<Stack gap={3}>
<Text as="label" size="sm" weight="medium" color="text-gray-300">
<Stack direction="row" align="center" gap={2}>
<Icon icon={FileText} size={4} color="text-primary-blue" />
Tell your story *
</Stack>
</Text>
<Box position="relative">
<textarea
value={basics.description ?? ''}
onChange={(e) =>
updateBasics({
description: e.target.value,
})
}
rows={4}
disabled={disabled}
className={`block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue text-sm disabled:opacity-60 disabled:cursor-not-allowed transition-all duration-150 ${
errors?.description ? 'ring-warning-amber' : 'ring-charcoal-outline'
}`}
placeholder="What makes your league special? Tell drivers what to expect..."
/>
</Box>
{errors?.description && (
<p className="text-xs text-warning-amber flex items-center gap-1.5">
<AlertCircle className="w-3 h-3" />
{errors.description}
</p>
<Text size="xs" color="text-warning-amber">
<Stack direction="row" align="center" gap={1.5}>
<Icon icon={AlertCircle} size={3} />
{errors.description}
</Stack>
</Text>
)}
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline/50 p-4 space-y-3">
<p className="text-xs text-gray-400">
<span className="font-medium text-gray-300">Great descriptions include:</span>
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="flex items-start gap-2">
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
<span className="text-xs text-gray-400">Racing style & pace</span>
</div>
<div className="flex items-start gap-2">
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
<span className="text-xs text-gray-400">Schedule & timezone</span>
</div>
<div className="flex items-start gap-2">
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
<span className="text-xs text-gray-400">Community vibe</span>
</div>
</div>
</div>
</div>
<Surface variant="muted" rounded="lg" border padding={4}>
<Box mb={3}>
<Text size="xs" color="text-gray-400">
<Text weight="medium" color="text-gray-300">Great descriptions include:</Text>
</Text>
</Box>
<Grid cols={3} gap={3}>
{[
'Racing style & pace',
'Schedule & timezone',
'Community vibe'
].map(item => (
<Stack key={item} direction="row" align="start" gap={2}>
<Icon icon={Check} size={3.5} color="text-performance-green" className="mt-0.5" />
<Text size="xs" color="text-gray-400">{item}</Text>
</Stack>
))}
</Grid>
</Surface>
</Stack>
{/* Game Platform */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
<Gamepad2 className="w-4 h-4 text-gray-400" />
Game platform
</label>
<div className="relative">
<Stack gap={2}>
<Text as="label" size="sm" weight="medium" color="text-gray-300">
<Stack direction="row" align="center" gap={2}>
<Icon icon={Gamepad2} size={4} color="text-gray-400" />
Game platform
</Stack>
</Text>
<Box position="relative">
<Input value="iRacing" disabled />
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-500">
More platforms soon
</div>
</div>
</div>
</div>
<Box position="absolute" right={3} top="50%" style={{ transform: 'translateY(-50%)' }}>
<Text size="xs" color="text-gray-500">
More platforms soon
</Text>
</Box>
</Box>
</Stack>
</Stack>
);
}
}

View File

@@ -1,8 +1,8 @@
'use client';
import { useState, useRef, useCallback } from 'react';
import Card from '@/ui/Card';
import Button from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import {
Move,
RotateCw,

View File

@@ -1,8 +1,8 @@
'use client';
import { useState } from 'react';
import Button from '../ui/Button';
import Input from '../ui/Input';
import { Button } from '@/ui/Button';
import { Input } from '@/ui/Input';
import { DollarSign, Calendar, User, TrendingUp } from 'lucide-react';
type FeeType = 'season' | 'monthly' | 'per_race';

View File

@@ -1,9 +1,16 @@
import React, { useState } from 'react';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
import Button from '@/ui/Button';
import { DriverSummaryPill } from '@/components/profile/DriverSummaryPill';
import { Button } from '@/ui/Button';
import { UserCog } from 'lucide-react';
import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Surface } from '@/ui/Surface';
import { Select } from '@/ui/Select';
import { Icon } from '@/ui/Icon';
interface LeagueOwnershipTransferProps {
settings: LeagueSettingsViewModel;
@@ -11,7 +18,7 @@ interface LeagueOwnershipTransferProps {
onTransferOwnership: (newOwnerId: string) => Promise<void>;
}
export default function LeagueOwnershipTransfer({
export function LeagueOwnershipTransfer({
settings,
currentDriverId,
onTransferOwnership
@@ -39,10 +46,12 @@ export default function LeagueOwnershipTransfer({
const ownerSummary = settings.owner;
return (
<div className="space-y-4">
<Stack gap={4}>
{/* League Owner */}
<div className="rounded-xl border border-charcoal-outline bg-gradient-to-br from-iron-gray/40 to-iron-gray/20 p-5">
<h3 className="text-sm font-semibold text-gray-400 mb-3">League Owner</h3>
<Surface variant="muted" rounded="xl" border padding={5}>
<Box mb={3}>
<Heading level={3}>League Owner</Heading>
</Box>
{ownerSummary ? (
<DriverSummaryPill
driver={new DriverViewModel({
@@ -58,20 +67,22 @@ export default function LeagueOwnershipTransfer({
rank={ownerSummary.rank}
/>
) : (
<p className="text-sm text-gray-500">Loading owner details...</p>
<Text size="sm" color="text-gray-500">Loading owner details...</Text>
)}
</div>
</Surface>
{/* Transfer Ownership - Owner Only */}
{settings.league.ownerId === currentDriverId && settings.members.length > 0 && (
<div className="rounded-xl border border-charcoal-outline bg-gradient-to-br from-iron-gray/40 to-iron-gray/20 p-5">
<div className="flex items-center gap-2 mb-3">
<UserCog className="w-4 h-4 text-gray-400" />
<h3 className="text-sm font-semibold text-gray-400">Transfer Ownership</h3>
</div>
<p className="text-xs text-gray-500 mb-4">
Transfer league ownership to another active member. You will become an admin.
</p>
<Surface variant="muted" rounded="xl" border padding={5}>
<Stack direction="row" align="center" gap={2} mb={3}>
<Icon icon={UserCog} size={4} color="text-gray-400" />
<Heading level={3}>Transfer Ownership</Heading>
</Stack>
<Box mb={4}>
<Text size="xs" color="text-gray-500">
Transfer league ownership to another active member. You will become an admin.
</Text>
</Box>
{!showTransferDialog ? (
<Button
@@ -81,21 +92,20 @@ export default function LeagueOwnershipTransfer({
Transfer Ownership
</Button>
) : (
<div className="space-y-3">
<select
<Stack gap={3}>
<Select
value={selectedNewOwner}
onChange={(e) => setSelectedNewOwner(e.target.value)}
className="w-full rounded-lg border border-charcoal-outline bg-charcoal-card px-3 py-2 text-sm text-white focus:border-primary-blue focus:outline-none"
>
<option value="">Select new owner...</option>
{settings.members.map((member) => (
<option key={member.driver.id} value={member.driver.id}>
{member.driver.name}
</option>
))}
</select>
options={[
{ value: '', label: 'Select new owner...' },
...settings.members.map((member) => ({
value: member.driver.id,
label: member.driver.name,
})),
]}
/>
<div className="flex gap-2">
<Stack direction="row" gap={2}>
<Button
variant="primary"
onClick={handleTransferOwnership}
@@ -113,11 +123,11 @@ export default function LeagueOwnershipTransfer({
>
Cancel
</Button>
</div>
</div>
</Stack>
</Stack>
)}
</div>
</Surface>
)}
</div>
</Stack>
);
}
}

View File

@@ -1,8 +1,8 @@
'use client';
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
import { useRegisterForRace } from "@/lib/hooks/race/useRegisterForRace";
import { useWithdrawFromRace } from "@/lib/hooks/race/useWithdrawFromRace";
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { useRegisterForRace } from "@/hooks/race/useRegisterForRace";
import { useWithdrawFromRace } from "@/hooks/race/useWithdrawFromRace";
import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
import type { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
@@ -10,7 +10,7 @@ import type { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueSchedu
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { EmptyState } from '@/components/shared/state/EmptyState';
import { useLeagueSchedule } from "@/lib/hooks/league/useLeagueSchedule";
import { useLeagueSchedule } from "@/hooks/league/useLeagueSchedule";
import { Calendar } from 'lucide-react';
interface LeagueScheduleProps {

File diff suppressed because it is too large Load Diff

View File

@@ -1,237 +0,0 @@
'use client';
import type { LeagueScoringConfigViewModel } from '@/lib/view-models/LeagueScoringConfigViewModel';
import { Trophy, Clock, Target, Zap, Info } from 'lucide-react';
type LeagueScoringConfigUi = LeagueScoringConfigViewModel & {
scoringPresetName?: string;
dropPolicySummary?: string;
championships?: Array<{
id: string;
name: string;
type: 'driver' | 'team' | 'nations' | 'trophy' | string;
sessionTypes: string[];
pointsPreview: Array<{ sessionType: string; position: number; points: number }>;
bonusSummary: string[];
dropPolicyDescription?: string;
}>;
};
interface LeagueScoringTabProps {
scoringConfig: LeagueScoringConfigViewModel | null;
practiceMinutes?: number;
qualifyingMinutes?: number;
sprintRaceMinutes?: number;
mainRaceMinutes?: number;
}
export default function LeagueScoringTab({
scoringConfig,
practiceMinutes,
qualifyingMinutes,
sprintRaceMinutes,
mainRaceMinutes,
}: LeagueScoringTabProps) {
if (!scoringConfig) {
return (
<div className="p-12 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-purple-500/10 flex items-center justify-center">
<Target className="w-8 h-8 text-purple-400" />
</div>
<h3 className="text-lg font-semibold text-white mb-2">No Scoring System</h3>
<p className="text-sm text-gray-400">
Scoring configuration is not available for this league yet
</p>
</div>
);
}
const ui = scoringConfig as unknown as LeagueScoringConfigUi;
const championships = ui.championships ?? [];
const primaryChampionship =
championships.find((c) => c.type === 'driver') ??
championships[0];
const resolvedPractice = practiceMinutes ?? 20;
const resolvedQualifying = qualifyingMinutes ?? 30;
const resolvedSprint = sprintRaceMinutes;
const resolvedMain = mainRaceMinutes ?? 40;
return (
<div className="space-y-6">
<div className="border-b border-charcoal-outline pb-4 space-y-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary-blue/10 flex items-center justify-center">
<Trophy className="w-5 h-5 text-primary-blue" />
</div>
<div>
<h2 className="text-xl font-semibold text-white">
Scoring overview
</h2>
<p className="text-sm text-gray-400">
{scoringConfig.gameName}{' '}
{ui.scoringPresetName
? `${ui.scoringPresetName}`
: '• Custom scoring'}{' '}
{ui.dropPolicySummary ? `${ui.dropPolicySummary}` : ''}
</p>
</div>
</div>
{primaryChampionship && (
<div className="space-y-3 pt-3">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-gray-400" />
<h3 className="text-sm font-medium text-gray-200">
Weekend structure & timings
</h3>
</div>
<div className="flex flex-wrap gap-2 text-xs">
{primaryChampionship.sessionTypes.map((session: string) => (
<span
key={session}
className="px-3 py-1 rounded-full bg-primary-blue/10 text-primary-blue border border-primary-blue/20 font-medium"
>
{session}
</span>
))}
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="p-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<p className="text-xs text-gray-400 mb-1">Practice</p>
<p className="text-sm font-medium text-white">
{resolvedPractice ? `${resolvedPractice} min` : '—'}
</p>
</div>
<div className="p-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<p className="text-xs text-gray-400 mb-1">Qualifying</p>
<p className="text-sm font-medium text-white">{resolvedQualifying} min</p>
</div>
<div className="p-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<p className="text-xs text-gray-400 mb-1">Sprint</p>
<p className="text-sm font-medium text-white">
{resolvedSprint ? `${resolvedSprint} min` : '—'}
</p>
</div>
<div className="p-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<p className="text-xs text-gray-400 mb-1">Main race</p>
<p className="text-sm font-medium text-white">{resolvedMain} min</p>
</div>
</div>
</div>
)}
</div>
{championships.map((championship) => (
<div
key={championship.id}
className="border border-charcoal-outline rounded-lg bg-iron-gray/40 p-4 space-y-4"
>
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-white">
{championship.name}
</h3>
<p className="text-xs uppercase tracking-wide text-gray-500">
{championship.type === 'driver'
? 'Driver championship'
: championship.type === 'team'
? 'Team championship'
: championship.type === 'nations'
? 'Nations championship'
: 'Trophy championship'}
</p>
</div>
{championship.sessionTypes.length > 0 && (
<div className="flex flex-wrap gap-1 justify-end">
{championship.sessionTypes.map((session: string) => (
<span
key={session}
className="px-2 py-0.5 rounded-full bg-charcoal-outline/60 text-xs text-gray-200"
>
{session}
</span>
))}
</div>
)}
</div>
{championship.pointsPreview.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-400 mb-2">
Points preview (top positions)
</h4>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-charcoal-outline/60">
<th className="text-left py-2 pr-2 text-gray-400">
Session
</th>
<th className="text-left py-2 px-2 text-gray-400">
Position
</th>
<th className="text-left py-2 px-2 text-gray-400">
Points
</th>
</tr>
</thead>
<tbody>
{championship.pointsPreview.map((row, index: number) => {
const typedRow = row as { sessionType: string; position: number; points: number };
return (
<tr
key={`${typedRow.sessionType}-${typedRow.position}-${index}`}
className="border-b border-charcoal-outline/30"
>
<td className="py-1.5 pr-2 text-gray-200">
{typedRow.sessionType}
</td>
<td className="py-1.5 px-2 text-gray-200">
P{typedRow.position}
</td>
<td className="py-1.5 px-2 text-white">
{typedRow.points}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{championship.bonusSummary.length > 0 && (
<div className="p-3 bg-yellow-500/5 border border-yellow-500/20 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Zap className="w-4 h-4 text-yellow-400" />
<h4 className="text-xs font-semibold text-yellow-400">
Bonus points
</h4>
</div>
<ul className="list-disc list-inside text-xs text-gray-300 space-y-1">
{championship.bonusSummary.map((item: string, index: number) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
)}
<div className="p-3 bg-primary-blue/5 border border-primary-blue/20 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Info className="w-4 h-4 text-primary-blue" />
<h4 className="text-xs font-semibold text-primary-blue">
Drop score policy
</h4>
</div>
<p className="text-xs text-gray-300">
{championship.dropPolicyDescription ?? ''}
</p>
</div>
</div>
))}
</div>
);
}

View File

@@ -1,204 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import LeagueCard from '@/components/leagues/LeagueCard';
interface LeagueSliderProps {
title: string;
icon: React.ElementType;
description: string;
leagues: LeagueSummaryViewModel[];
onLeagueClick: (id: string) => void;
autoScroll?: boolean;
iconColor?: string;
scrollSpeedMultiplier?: number;
scrollDirection?: 'left' | 'right';
}
export const LeagueSlider = ({
title,
icon: Icon,
description,
leagues,
onLeagueClick,
autoScroll = true,
iconColor = 'text-primary-blue',
scrollSpeedMultiplier = 1,
scrollDirection = 'right',
}: LeagueSliderProps) => {
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
const [isHovering, setIsHovering] = useState(false);
const animationRef = useRef<number | null>(null);
const scrollPositionRef = useRef(0);
const checkScrollButtons = useCallback(() => {
if (scrollRef.current) {
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setCanScrollLeft(scrollLeft > 0);
setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 10);
}
}, []);
const scroll = useCallback((direction: 'left' | 'right') => {
if (scrollRef.current) {
const cardWidth = 340;
const scrollAmount = direction === 'left' ? -cardWidth : cardWidth;
// Update the ref so auto-scroll continues from new position
scrollPositionRef.current = scrollRef.current.scrollLeft + scrollAmount;
scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
}
}, []);
// Initialize scroll position for left-scrolling sliders
useEffect(() => {
if (scrollDirection === 'left' && scrollRef.current) {
const { scrollWidth, clientWidth } = scrollRef.current;
scrollPositionRef.current = scrollWidth - clientWidth;
scrollRef.current.scrollLeft = scrollPositionRef.current;
}
}, [scrollDirection, leagues.length]);
// Smooth continuous auto-scroll using requestAnimationFrame with variable speed and direction
useEffect(() => {
// Allow scroll even with just 2 leagues (minimum threshold = 1)
if (!autoScroll || leagues.length <= 1) return;
const scrollContainer = scrollRef.current;
if (!scrollContainer) return;
let lastTimestamp = 0;
// Base speed with multiplier for variation between sliders
const baseSpeed = 0.025;
const scrollSpeed = baseSpeed * scrollSpeedMultiplier;
const directionMultiplier = scrollDirection === 'left' ? -1 : 1;
const animate = (timestamp: number) => {
if (!isHovering && scrollContainer) {
const delta = lastTimestamp ? timestamp - lastTimestamp : 0;
lastTimestamp = timestamp;
scrollPositionRef.current += scrollSpeed * delta * directionMultiplier;
const { scrollWidth, clientWidth } = scrollContainer;
const maxScroll = scrollWidth - clientWidth;
// Handle wrap-around for both directions
if (scrollDirection === 'right' && scrollPositionRef.current >= maxScroll) {
scrollPositionRef.current = 0;
} else if (scrollDirection === 'left' && scrollPositionRef.current <= 0) {
scrollPositionRef.current = maxScroll;
}
scrollContainer.scrollLeft = scrollPositionRef.current;
} else {
lastTimestamp = timestamp;
}
animationRef.current = requestAnimationFrame(animate);
};
animationRef.current = requestAnimationFrame(animate);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [autoScroll, leagues.length, isHovering, scrollSpeedMultiplier, scrollDirection]);
// Sync scroll position when user manually scrolls
useEffect(() => {
const scrollContainer = scrollRef.current;
if (!scrollContainer) return;
const handleScroll = () => {
scrollPositionRef.current = scrollContainer.scrollLeft;
checkScrollButtons();
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => scrollContainer.removeEventListener('scroll', handleScroll);
}, [checkScrollButtons]);
if (leagues.length === 0) return null;
return (
<div className="mb-10">
{/* Section header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-xl bg-iron-gray border border-charcoal-outline`}>
<Icon className={`w-5 h-5 ${iconColor}`} />
</div>
<div>
<h2 className="text-lg font-semibold text-white">{title}</h2>
<p className="text-xs text-gray-500">{description}</p>
</div>
<span className="ml-2 px-2 py-0.5 rounded-full text-xs bg-charcoal-outline/50 text-gray-400">
{leagues.length}
</span>
</div>
{/* Navigation arrows */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => scroll('left')}
disabled={!canScrollLeft}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-all ${
canScrollLeft
? 'bg-iron-gray border border-charcoal-outline text-white hover:border-primary-blue hover:text-primary-blue'
: 'bg-iron-gray/30 border border-charcoal-outline/30 text-gray-600 cursor-not-allowed'
}`}
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => scroll('right')}
disabled={!canScrollRight}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-all ${
canScrollRight
? 'bg-iron-gray border border-charcoal-outline text-white hover:border-primary-blue hover:text-primary-blue'
: 'bg-iron-gray/30 border border-charcoal-outline/30 text-gray-600 cursor-not-allowed'
}`}
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
{/* Scrollable container with fade edges */}
<div className="relative">
{/* Left fade gradient */}
<div className="absolute left-0 top-0 bottom-4 w-12 bg-gradient-to-r from-deep-graphite to-transparent z-10 pointer-events-none" />
{/* Right fade gradient */}
<div className="absolute right-0 top-0 bottom-4 w-12 bg-gradient-to-l from-deep-graphite to-transparent z-10 pointer-events-none" />
<div
ref={scrollRef}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
className="league-slider__scroll flex gap-4 overflow-x-auto pb-4 px-4"
style={{
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
>
<style>{`
.league-slider__scroll::-webkit-scrollbar {
display: none;
}
`}</style>
{leagues.map((league) => (
<div key={league.id} className="flex-shrink-0 w-[320px] h-full">
<LeagueCard league={league} onClick={() => onLeagueClick(league.id)} />
</div>
))}
</div>
</div>
</div>
);
};

View File

@@ -1,45 +0,0 @@
import { Search } from 'lucide-react';
import Button from '@/ui/Button';
import Card from '@/ui/Card';
interface NoResultsStateProps {
icon?: React.ElementType;
message?: string;
searchQuery?: string;
actionLabel?: string;
onAction?: () => void;
children?: React.ReactNode;
className?: string;
}
export function NoResultsState({
icon: Icon = Search,
message,
searchQuery,
actionLabel = "Clear filters",
onAction,
children,
className
}: NoResultsStateProps) {
const defaultMessage = message || `No leagues found${searchQuery ? ` matching "${searchQuery}"` : ' in this category'}`;
return (
<Card className={`text-center py-12 ${className || ''}`}>
<div className="flex flex-col items-center gap-4">
<Icon className="w-10 h-10 text-gray-600" />
<p className="text-gray-400">
{defaultMessage}
</p>
{children}
{actionLabel && onAction && (
<Button
variant="secondary"
onClick={onAction}
>
{actionLabel}
</Button>
)}
</div>
</Card>
);
}

View File

@@ -1,57 +0,0 @@
'use client';
import { useState } from 'react';
import { MoreVertical, Edit, Trash2 } from 'lucide-react';
import Button from '../ui/Button';
interface PenaltyCardMenuProps {
onEdit: () => void;
onVoid: () => void;
}
export default function PenaltyCardMenu({ onEdit, onVoid }: PenaltyCardMenuProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="relative">
<Button
variant="secondary"
className="p-2 w-8 h-8"
onClick={() => setIsOpen(!isOpen)}
>
<MoreVertical className="w-4 h-4" />
</Button>
{isOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
<div className="absolute right-0 mt-1 w-32 bg-deep-graphite border border-charcoal-outline rounded-lg shadow-lg z-20">
<button
onClick={() => {
onEdit();
setIsOpen(false);
}}
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-iron-gray/50 flex items-center gap-2"
>
<Edit className="w-4 h-4" />
Edit
</button>
<button
onClick={() => {
onVoid();
setIsOpen(false);
}}
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-iron-gray/50 flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Void
</button>
</div>
</>
)}
</div>
);
}

View File

@@ -1,11 +1,11 @@
"use client";
import { useState, useEffect } from "react";
import { ProtestViewModel } from "../../lib/view-models/ProtestViewModel";
import { RaceViewModel } from "../../lib/view-models/RaceViewModel";
import { DriverViewModel } from "../../lib/view-models/DriverViewModel";
import Card from "../ui/Card";
import Button from "../ui/Button";
import { ProtestViewModel } from "@/lib/view-models/ProtestViewModel";
import { RaceViewModel } from "@/lib/view-models/RaceViewModel";
import { DriverViewModel } from "@/lib/view-models/DriverViewModel";
import { Card } from "@/ui/Card";
import { Button } from "@/ui/Button";
import { Clock, Grid3x3, TrendingDown, AlertCircle, Filter, Flag } from "lucide-react";
interface PenaltyHistoryListProps {

View File

@@ -1,10 +1,10 @@
"use client";
import { ProtestViewModel } from "../../lib/view-models/ProtestViewModel";
import { RaceViewModel } from "../../lib/view-models/RaceViewModel";
import { DriverViewModel } from "../../lib/view-models/DriverViewModel";
import Card from "../ui/Card";
import Button from "../ui/Button";
import { ProtestViewModel } from "@/lib/view-models/ProtestViewModel";
import { RaceViewModel } from "@/lib/view-models/RaceViewModel";
import { DriverViewModel } from "@/lib/view-models/DriverViewModel";
import { Card } from "@/ui/Card";
import { Button } from "@/ui/Button";
import Link from "next/link";
import { AlertCircle, Video, ChevronRight, Flag, Clock, AlertTriangle } from "lucide-react";

View File

@@ -1,73 +0,0 @@
import Card from '@/ui/Card';
interface PointsBreakdownTableProps {
positionPoints: Array<{ position: number; points: number }>;
}
export function PointsBreakdownTable({ positionPoints }: PointsBreakdownTableProps) {
const getPositionStyle = (position: number): string => {
if (position === 1) return 'bg-yellow-500 text-black';
if (position === 2) return 'bg-gray-400 text-black';
if (position === 3) return 'bg-amber-600 text-white';
return 'bg-charcoal-outline text-white';
};
const getRowHighlight = (position: number): string => {
if (position === 1) return 'bg-yellow-500/5 border-l-2 border-l-yellow-500';
if (position === 2) return 'bg-gray-400/5 border-l-2 border-l-gray-400';
if (position === 3) return 'bg-amber-600/5 border-l-2 border-l-amber-600';
return 'border-l-2 border-l-transparent';
};
const formatPosition = (position: number): string => {
if (position === 1) return '1st';
if (position === 2) return '2nd';
if (position === 3) return '3rd';
return `${position}th`;
};
return (
<Card className="overflow-hidden">
<div className="mb-4">
<h3 className="text-lg font-semibold text-white">Position Points</h3>
<p className="text-sm text-gray-400 mt-1">Points awarded by finishing position</p>
</div>
<div className="overflow-x-auto -mx-6 -mb-6">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-charcoal-outline bg-deep-graphite">
<th className="text-left py-3 px-6 font-medium text-gray-400 uppercase text-xs tracking-wider">
Position
</th>
<th className="text-right py-3 px-6 font-medium text-gray-400 uppercase text-xs tracking-wider">
Points
</th>
</tr>
</thead>
<tbody>
{positionPoints.map(({ position, points }) => (
<tr
key={position}
className={`border-b border-charcoal-outline/50 transition-colors hover:bg-iron-gray/30 ${getRowHighlight(position)}`}
>
<td className="py-3 px-6">
<div className="flex items-center gap-3">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold ${getPositionStyle(position)}`}>
{position}
</div>
<span className="text-white font-medium">{formatPosition(position)}</span>
</div>
</td>
<td className="py-3 px-6 text-right">
<span className="text-white font-semibold tabular-nums">{points}</span>
<span className="text-gray-500 ml-1">pts</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
}

View File

@@ -1,314 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Button from '../ui/Button';
import Input from '../ui/Input';
import { useAllLeagues } from "@/lib/hooks/league/useAllLeagues";
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
interface ScheduleRaceFormData {
leagueId: string;
track: string;
car: string;
sessionType: 'practice' | 'qualifying' | 'race';
scheduledDate: string;
scheduledTime: string;
}
interface ScheduledRaceViewModel {
id: string;
track: string;
car: string;
scheduledAt: string;
}
interface ScheduleRaceFormProps {
preSelectedLeagueId?: string;
onSuccess?: (race: ScheduledRaceViewModel) => void;
onCancel?: () => void;
}
export default function ScheduleRaceForm({
preSelectedLeagueId,
onSuccess,
onCancel
}: ScheduleRaceFormProps) {
const router = useRouter();
const { data: leagues = [], isLoading, error } = useAllLeagues();
const [formData, setFormData] = useState<ScheduleRaceFormData>({
leagueId: preSelectedLeagueId || '',
track: '',
car: '',
sessionType: 'race',
scheduledDate: '',
scheduledTime: '',
});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const validateForm = (): boolean => {
const errors: Record<string, string> = {};
if (!formData.leagueId) {
errors.leagueId = 'League is required';
}
if (!formData.track.trim()) {
errors.track = 'Track is required';
}
if (!formData.car.trim()) {
errors.car = 'Car is required';
}
if (!formData.scheduledDate) {
errors.scheduledDate = 'Date is required';
}
if (!formData.scheduledTime) {
errors.scheduledTime = 'Time is required';
}
// Validate future date
if (formData.scheduledDate && formData.scheduledTime) {
const scheduledDateTime = new Date(`${formData.scheduledDate}T${formData.scheduledTime}`);
const now = new Date();
if (scheduledDateTime <= now) {
errors.scheduledDate = 'Date must be in the future';
}
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
try {
// Create race using the race service
// Note: This assumes the race service has a create method
// If not available, we'll need to implement it or use an alternative approach
const raceData = {
leagueId: formData.leagueId,
track: formData.track,
car: formData.car,
sessionType: formData.sessionType,
scheduledAt: new Date(`${formData.scheduledDate}T${formData.scheduledTime}`).toISOString(),
};
// For now, we'll simulate race creation since the race service may not have create method
// In a real implementation, this would call raceService.createRace(raceData)
const createdRace: ScheduledRaceViewModel = {
id: `race-${Date.now()}`,
track: formData.track,
car: formData.car,
scheduledAt: new Date(`${formData.scheduledDate}T${formData.scheduledTime}`).toISOString(),
};
if (onSuccess) {
onSuccess(createdRace);
} else {
router.push(`/races/${createdRace.id}`);
}
} catch (err) {
// Error handling is now done through the component state
console.error('Failed to create race:', err);
}
};
const handleChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear validation error for this field
if (validationErrors[field]) {
setValidationErrors(prev => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
return (
<>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400">
{error.message}
</div>
)}
{/* Companion App Notice */}
<div className="p-4 rounded-lg bg-iron-gray border border-charcoal-outline">
<div className="flex items-start gap-3">
<div className="flex items-center gap-2 flex-1">
<input
type="checkbox"
disabled
className="w-4 h-4 rounded border-charcoal-outline bg-deep-graphite text-primary-blue opacity-50 cursor-not-allowed"
/>
<label className="text-sm text-gray-400">
Use Companion App
</label>
<button
type="button"
className="text-gray-500 hover:text-gray-400 transition-colors"
title="Companion automation available in production. For alpha, races are created manually."
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
</div>
<p className="text-xs text-gray-500 mt-2 ml-6">
Companion automation available in production. For alpha, races are created manually.
</p>
</div>
{/* League Selection */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
League *
</label>
<select
value={formData.leagueId}
onChange={(e) => handleChange('leagueId', e.target.value)}
disabled={!!preSelectedLeagueId}
className={`
w-full px-4 py-2 bg-deep-graphite border rounded-lg text-white
focus:outline-none focus:ring-2 focus:ring-primary-blue
disabled:opacity-50 disabled:cursor-not-allowed
${validationErrors.leagueId ? 'border-red-500' : 'border-charcoal-outline'}
`}
>
<option value="">Select a league</option>
{leagues.map((league) => (
<option key={league.id} value={league.id}>
{league.name}
</option>
))}
</select>
{validationErrors.leagueId && (
<p className="mt-1 text-sm text-red-400">{validationErrors.leagueId}</p>
)}
</div>
{/* Track */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Track *
</label>
<Input
type="text"
value={formData.track}
onChange={(e) => handleChange('track', e.target.value)}
placeholder="e.g., Spa-Francorchamps"
className={validationErrors.track ? 'border-red-500' : ''}
/>
{validationErrors.track && (
<p className="mt-1 text-sm text-red-400">{validationErrors.track}</p>
)}
<p className="mt-1 text-xs text-gray-500">Enter the iRacing track name</p>
</div>
{/* Car */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Car *
</label>
<Input
type="text"
value={formData.car}
onChange={(e) => handleChange('car', e.target.value)}
placeholder="e.g., Porsche 911 GT3 R"
className={validationErrors.car ? 'border-red-500' : ''}
/>
{validationErrors.car && (
<p className="mt-1 text-sm text-red-400">{validationErrors.car}</p>
)}
<p className="mt-1 text-xs text-gray-500">Enter the iRacing car name</p>
</div>
{/* Session Type */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Session Type *
</label>
<select
value={formData.sessionType}
onChange={(e) => handleChange('sessionType', e.target.value)}
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue"
>
<option value="practice">Practice</option>
<option value="qualifying">Qualifying</option>
<option value="race">Race</option>
</select>
</div>
{/* Date and Time */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Date *
</label>
<Input
type="date"
value={formData.scheduledDate}
onChange={(e) => handleChange('scheduledDate', e.target.value)}
className={validationErrors.scheduledDate ? 'border-red-500' : ''}
/>
{validationErrors.scheduledDate && (
<p className="mt-1 text-sm text-red-400">{validationErrors.scheduledDate}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Time *
</label>
<Input
type="time"
value={formData.scheduledTime}
onChange={(e) => handleChange('scheduledTime', e.target.value)}
className={validationErrors.scheduledTime ? 'border-red-500' : ''}
/>
{validationErrors.scheduledTime && (
<p className="mt-1 text-sm text-red-400">{validationErrors.scheduledTime}</p>
)}
</div>
</div>
{/* Actions */}
<div className="flex gap-3">
<Button
type="submit"
variant="primary"
disabled={isLoading}
className="flex-1"
>
{isLoading ? 'Creating...' : 'Schedule Race'}
</Button>
{onCancel && (
<Button
type="button"
variant="secondary"
onClick={onCancel}
disabled={isLoading}
>
Cancel
</Button>
)}
</div>
</form>
</>
);
}

View File

@@ -1,54 +0,0 @@
import Card from '@/ui/Card';
interface ScoringOverviewCardProps {
gameName: string;
scoringPresetName?: string;
dropPolicySummary: string;
totalChampionships: number;
}
export function ScoringOverviewCard({
gameName,
scoringPresetName,
dropPolicySummary,
totalChampionships
}: ScoringOverviewCardProps) {
return (
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">Scoring System</h2>
<p className="text-sm text-gray-400 mt-1">Points allocation and championship rules</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">{scoringPresetName || 'Custom'}</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1 font-medium">Platform</p>
<p className="text-lg font-semibold text-white">{gameName}</p>
</div>
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1 font-medium">Championships</p>
<p className="text-lg font-semibold text-white">{totalChampionships}</p>
</div>
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1 font-medium">Drop Policy</p>
<p className="text-lg font-semibold text-white truncate" title={dropPolicySummary}>
{dropPolicySummary.includes('Best') ? dropPolicySummary.split(' ').slice(0, 3).join(' ') :
dropPolicySummary.includes('Worst') ? dropPolicySummary.split(' ').slice(0, 3).join(' ') :
'All count'}
</p>
</div>
</div>
<div className="p-4 bg-deep-graphite rounded-lg border border-charcoal-outline">
<p className="text-sm text-gray-400">{dropPolicySummary}</p>
</div>
</Card>
);
}

View File

@@ -1,95 +0,0 @@
import { useState } from 'react';
import { Search, Filter } from 'lucide-react';
import Input from '@/ui/Input';
import Button from '@/ui/Button';
interface Category {
id: string;
label: string;
icon: React.ElementType;
description: string;
color?: string;
}
interface SearchAndFilterBarProps {
searchQuery: string;
onSearchChange: (query: string) => void;
activeCategory: string;
onCategoryChange: (category: string) => void;
categories: Category[];
leaguesByCategory: Record<string, any[]>;
className?: string;
}
export function SearchAndFilterBar({
searchQuery,
onSearchChange,
activeCategory,
onCategoryChange,
categories,
leaguesByCategory,
className,
}: SearchAndFilterBarProps) {
const [showFilters, setShowFilters] = useState(false);
return (
<div className={`mb-6 ${className || ''}`}>
<div className="flex flex-col lg:flex-row gap-4">
{/* Search */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<Input
type="text"
placeholder="Search leagues by name, description, or game..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-11"
/>
</div>
{/* Filter toggle (mobile) */}
<Button
type="button"
variant="secondary"
onClick={() => setShowFilters(!showFilters)}
className="lg:hidden flex items-center gap-2"
>
<Filter className="w-4 h-4" />
Filters
</Button>
</div>
{/* Category Tabs */}
<div className={`mt-4 ${showFilters ? 'block' : 'hidden lg:block'}`}>
<div className="flex flex-wrap gap-2">
{categories.map((category) => {
const Icon = category.icon;
const count = leaguesByCategory[category.id]?.length || 0;
const isActive = activeCategory === category.id;
return (
<button
key={category.id}
type="button"
onClick={() => onCategoryChange(category.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all duration-200 ${
isActive
? 'bg-primary-blue text-white shadow-[0_0_15px_rgba(25,140,255,0.3)]'
: 'bg-iron-gray/60 text-gray-400 border border-charcoal-outline hover:border-gray-500 hover:text-white'
}`}
>
<Icon className={`w-3.5 h-3.5 ${!isActive && category.color ? category.color : ''}`} />
<span>{category.label}</span>
{count > 0 && (
<span className={`px-1.5 py-0.5 rounded-full text-[10px] ${isActive ? 'bg-white/20' : 'bg-charcoal-outline/50'}`}>
{count}
</span>
)}
</button>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -1,87 +0,0 @@
interface SeasonStatistics {
racesCompleted: number;
totalRaces: number;
averagePoints: number;
highestScore: number;
totalPoints: number;
}
interface SeasonStatsCardProps {
stats: SeasonStatistics;
}
export function SeasonStatsCard({ stats }: SeasonStatsCardProps) {
const completionPercentage = stats.totalRaces > 0
? Math.round((stats.racesCompleted / stats.totalRaces) * 100)
: 0;
if (stats.racesCompleted === 0) {
return null;
}
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
📈 Season Statistics
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Your performance this season
</p>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="bg-gradient-to-br from-blue-50 to-cyan-50 dark:from-blue-950/20 dark:to-cyan-950/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<p className="text-xs text-blue-600 dark:text-blue-400 uppercase tracking-wider mb-1 font-medium">
Races Completed
</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{stats.racesCompleted}
<span className="text-lg text-gray-500 dark:text-gray-400">/{stats.totalRaces}</span>
</p>
</div>
<div className="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20 rounded-lg p-4 border border-green-200 dark:border-green-800">
<p className="text-xs text-green-600 dark:text-green-400 uppercase tracking-wider mb-1 font-medium">
Average Points
</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{stats.averagePoints.toFixed(1)}
</p>
</div>
<div className="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 rounded-lg p-4 border border-purple-200 dark:border-purple-800">
<p className="text-xs text-purple-600 dark:text-purple-400 uppercase tracking-wider mb-1 font-medium">
Highest Score
</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{stats.highestScore}
</p>
</div>
<div className="bg-gradient-to-br from-orange-50 to-red-50 dark:from-orange-950/20 dark:to-red-950/20 rounded-lg p-4 border border-orange-200 dark:border-orange-800">
<p className="text-xs text-orange-600 dark:text-orange-400 uppercase tracking-wider mb-1 font-medium">
Total Points
</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{stats.totalPoints}
</p>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-600 dark:text-gray-400">Season Progress</span>
<span className="font-semibold text-gray-900 dark:text-white">{completionPercentage}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
<div
className="bg-gradient-to-r from-blue-500 to-purple-500 h-3 rounded-full transition-all duration-500 ease-out"
style={{ width: `${completionPercentage}%` }}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,207 +0,0 @@
'use client';
import { motion, useReducedMotion, useMotionValue, useSpring, useTransform } from 'framer-motion';
import { useEffect, useState } from 'react';
export default function RatingFactorsMockup() {
const shouldReduceMotion = useReducedMotion();
const [isHovered, setIsHovered] = useState<number | null>(null);
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
setIsMobile(window.innerWidth < 768);
}, []);
const factors = [
{ name: 'Position', value: 85, color: 'text-primary-blue', bgColor: 'bg-primary-blue' },
{ name: 'Field Strength', value: 72, color: 'text-neon-aqua', bgColor: 'bg-neon-aqua' },
{ name: 'Consistency', value: 68, color: 'text-performance-green', bgColor: 'bg-performance-green' },
{ name: 'Clean Driving', value: 91, color: 'text-warning-amber', bgColor: 'bg-warning-amber' },
{ name: 'Reliability', value: 88, color: 'text-primary-blue', bgColor: 'bg-primary-blue' },
{ name: 'Team Points', value: 79, color: 'text-performance-green', bgColor: 'bg-performance-green' },
];
if (isMobile) {
return (
<div className="relative w-full h-full bg-gradient-to-br from-deep-graphite via-iron-gray to-deep-graphite rounded-lg p-3 overflow-hidden">
<div className="text-center mb-4">
<div className="h-5 w-40 bg-white/10 rounded mx-auto mb-2"></div>
<div className="h-3 w-32 bg-white/5 rounded mx-auto"></div>
</div>
<div className="grid grid-cols-2 gap-3 mb-4">
{factors.slice(0, 4).map((factor) => (
<div key={factor.name} className="w-full">
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-gray-400">{factor.name}</div>
<span className={`text-sm font-semibold font-mono ${factor.color}`}>
{factor.value}
</span>
</div>
<div className="relative h-2.5 bg-charcoal-outline rounded-full overflow-hidden">
<div
className={`absolute inset-y-0 left-0 ${factor.bgColor} rounded-full`}
style={{ width: `${factor.value}%` }}
/>
</div>
</div>
))}
</div>
<div className="flex items-center justify-center bg-iron-gray/50 rounded-lg p-3 border border-charcoal-outline">
<div className="text-center">
<div className="h-2.5 w-20 bg-white/10 rounded mb-2 mx-auto"></div>
<div className="text-3xl font-bold text-primary-blue font-mono">1342</div>
</div>
</div>
</div>
);
}
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: shouldReduceMotion ? 0 : 0.1 }
}
};
const itemVariants = {
hidden: { opacity: 0, y: shouldReduceMotion ? 0 : 20 },
visible: {
opacity: 1,
y: 0,
transition: { type: 'spring' as const, stiffness: 200, damping: 20 }
}
};
return (
<div className="relative w-full h-full bg-gradient-to-br from-deep-graphite via-iron-gray to-deep-graphite rounded-lg p-3 md:p-4 lg:p-6 overflow-hidden">
<motion.div
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-3 md:mb-4 lg:mb-6"
>
<div className="h-6 md:h-6 lg:h-7 w-44 md:w-56 bg-white/10 rounded mx-auto mb-2.5 md:mb-3"></div>
<div className="h-4 md:h-4 w-32 md:w-40 bg-white/5 rounded mx-auto"></div>
</motion.div>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="grid grid-cols-2 md:grid-cols-3 gap-3 md:gap-4 mb-3 md:mb-4 lg:mb-6 max-w-4xl mx-auto"
>
{factors.map((factor, index) => (
<motion.div
key={factor.name}
variants={itemVariants}
className="flex flex-col items-center"
onHoverStart={() => !shouldReduceMotion && setIsHovered(index)}
onHoverEnd={() => setIsHovered(null)}
>
<RatingFactor
value={factor.value}
color={factor.color}
bgColor={factor.bgColor}
name={factor.name}
shouldReduceMotion={shouldReduceMotion ?? false}
isHovered={isHovered === index}
/>
</motion.div>
))}
</motion.div>
<motion.div
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: shouldReduceMotion ? 0 : 0.6 }}
className="flex items-center justify-center gap-3 md:gap-3 bg-iron-gray/50 rounded-lg p-4 md:p-4 lg:p-5 border border-charcoal-outline shadow-[0_4px_24px_rgba(0,0,0,0.4)] backdrop-blur-sm max-w-md mx-auto"
>
<div className="text-center">
<div className="h-3 md:h-3 w-24 md:w-28 bg-white/10 rounded mb-2 md:mb-2 mx-auto"></div>
<div className="h-12 md:h-12 w-24 md:w-24 bg-charcoal-outline rounded flex items-center justify-center border border-primary-blue/30">
<AnimatedRating shouldReduceMotion={shouldReduceMotion ?? false} />
</div>
</div>
</motion.div>
</div>
);
}
function RatingFactor({
value,
color,
bgColor,
name,
shouldReduceMotion,
isHovered
}: {
value: number;
color: string;
bgColor: string;
name: string;
shouldReduceMotion: boolean;
isHovered: boolean;
}) {
const progress = useMotionValue(0);
const smoothProgress = useSpring(progress, { stiffness: 60, damping: 25 });
const width = useTransform(smoothProgress, (v: number) => `${v}%`);
useEffect(() => {
if (shouldReduceMotion) {
progress.set(value);
} else {
const timeout = setTimeout(() => progress.set(value), 200);
return () => clearTimeout(timeout);
}
}, [value, shouldReduceMotion, progress]);
return (
<motion.div
className="w-full"
whileHover={shouldReduceMotion ? {} : { scale: 1.05 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
<div className="flex items-center justify-between mb-2.5 md:mb-3">
<div className="text-sm md:text-base lg:text-lg font-light text-gray-400 tracking-wide">{name}</div>
<motion.span
className={`text-sm md:text-base font-semibold font-mono ${color}`}
animate={isHovered && !shouldReduceMotion ? { scale: 1.1 } : { scale: 1 }}
>
{value}
</motion.span>
</div>
<div className="relative h-3 md:h-3.5 lg:h-4 bg-charcoal-outline rounded-full overflow-hidden">
<motion.div
className={`absolute inset-y-0 left-0 ${bgColor} rounded-full`}
style={{ width }}
animate={isHovered && !shouldReduceMotion ? {
boxShadow: `0 0 12px currentColor`
} : {}}
/>
</div>
</motion.div>
);
}
function AnimatedRating({ shouldReduceMotion }: { shouldReduceMotion: boolean }) {
const count = useMotionValue(0);
const rounded = useTransform(count, (v: number) => Math.round(v));
const spring = useSpring(count, { stiffness: 50, damping: 25 });
useEffect(() => {
if (shouldReduceMotion) {
count.set(1342);
} else {
const timeout = setTimeout(() => count.set(1342), 800);
return () => clearTimeout(timeout);
}
}, [shouldReduceMotion, count]);
return (
<motion.span className="text-3xl md:text-4xl lg:text-5xl font-bold text-primary-blue font-mono">
{shouldReduceMotion ? 1342 : <motion.span>{rounded}</motion.span>}
</motion.span>
);
}

View File

@@ -1,7 +1,14 @@
import { useRef, ChangeEvent } from 'react';
import { Camera, Upload, Loader2, Sparkles, Palette, Check, User } from 'lucide-react';
import Button from '@/ui/Button';
import Heading from '@/ui/Heading';
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 { Icon } from '@/ui/Icon';
import { Grid } from '@/ui/Grid';
import { Surface } from '@/ui/Surface';
import { Image } from '@/ui/Image';
export type RacingSuitColor =
| 'red'
@@ -86,40 +93,38 @@ export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGen
};
return (
// eslint-disable-next-line gridpilot-rules/no-raw-html-in-app
<div className="space-y-6">
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<div>
<Heading level={2} className="text-xl mb-1 flex items-center gap-2">
<Camera className="w-5 h-5 text-primary-blue" />
<Stack gap={6}>
<Box>
<Heading level={2} icon={<Icon icon={Camera} size={5} color="text-primary-blue" />}>
Create Your Racing Avatar
</Heading>
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<p className="text-sm text-gray-400">
<Text size="sm" color="text-gray-400" block mt={1}>
Upload a photo and we will generate a unique racing avatar for you
</p>
</div>
</Text>
</Box>
{/* Photo Upload */}
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<div>
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<label className="block text-sm font-medium text-gray-300 mb-3">
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={3}>
Upload Your Photo *
</label>
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<div className="flex gap-6">
</Text>
<Stack direction="row" gap={6}>
{/* Upload Area */}
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<div
<Box
onClick={() => fileInputRef.current?.click()}
className={`relative flex-1 flex flex-col items-center justify-center p-6 rounded-xl border-2 border-dashed cursor-pointer transition-all ${
avatarInfo.facePhoto
? 'border-performance-green bg-performance-green/5'
: errors.facePhoto
? 'border-red-500 bg-red-500/5'
: 'border-charcoal-outline hover:border-primary-blue hover:bg-primary-blue/5'
}`}
flex={1}
display="flex"
flexDirection="column"
align="center"
justify="center"
padding={6}
rounded="xl"
border
borderColor={avatarInfo.facePhoto ? 'performance-green' : errors.facePhoto ? 'racing-red' : 'charcoal-outline'}
backgroundColor={avatarInfo.facePhoto ? 'performance-green' : errors.facePhoto ? 'racing-red' : 'transparent'}
opacity={avatarInfo.facePhoto || errors.facePhoto ? 0.1 : 1}
className="border-2 border-dashed cursor-pointer transition-all hover:border-primary-blue hover:bg-primary-blue/5"
position="relative"
>
<input
ref={fileInputRef}
@@ -130,91 +135,81 @@ export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGen
/>
{avatarInfo.isValidating ? (
<>
<Loader2 className="w-10 h-10 text-primary-blue animate-spin mb-3" />
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<p className="text-sm text-gray-400">Validating photo...</p>
</>
<Stack align="center" center>
<Icon icon={Loader2} size={10} color="text-primary-blue" className="animate-spin mb-3" />
<Text size="sm" color="text-gray-400">Validating photo...</Text>
</Stack>
) : avatarInfo.facePhoto ? (
<>
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<div className="w-24 h-24 rounded-xl overflow-hidden mb-3 ring-2 ring-performance-green">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
<Stack align="center" center>
<Box width={24} height={24} rounded="xl" className="overflow-hidden mb-3 ring-2 ring-performance-green">
<Image
src={avatarInfo.facePhoto}
alt="Your photo"
width={96}
height={96}
className="w-full h-full object-cover"
/>
</div>
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<p className="text-sm text-performance-green flex items-center gap-1">
<Check className="w-4 h-4" />
Photo uploaded
</p>
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<p className="text-xs text-gray-500 mt-1">Click to change</p>
</>
</Box>
<Text size="sm" color="text-performance-green" block>
<Stack direction="row" align="center" gap={1}>
<Icon icon={Check} size={4} />
Photo uploaded
</Stack>
</Text>
<Text size="xs" color="text-gray-500" mt={1}>Click to change</Text>
</Stack>
) : (
<>
<Upload className="w-10 h-10 text-gray-500 mb-3" />
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<p className="text-sm text-gray-300 font-medium mb-1">
<Stack align="center" center>
<Icon icon={Upload} size={10} color="text-gray-500" className="mb-3" />
<Text size="sm" color="text-gray-300" weight="medium" block mb={1}>
Drop your photo here or click to upload
</p>
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<p className="text-xs text-gray-500">
</Text>
<Text size="xs" color="text-gray-500">
JPEG or PNG, max 5MB
</p>
</>
</Text>
</Stack>
)}
</div>
</Box>
{/* Preview area */}
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<div className="w-32 flex flex-col items-center justify-center">
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<div className="w-24 h-24 rounded-xl bg-iron-gray border border-charcoal-outline flex items-center justify-center overflow-hidden">
<Stack width={32} align="center" center>
<Surface variant="muted" rounded="xl" border width={24} height={24} display="flex" center className="overflow-hidden">
{(() => {
const selectedAvatarUrl =
avatarInfo.selectedAvatarIndex !== null
? avatarInfo.generatedAvatars[avatarInfo.selectedAvatarIndex]
: undefined;
if (!selectedAvatarUrl) {
return <User className="w-8 h-8 text-gray-600" />;
return <Icon icon={User} size={8} color="text-gray-600" />;
}
return (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={selectedAvatarUrl} alt="Selected avatar" className="w-full h-full object-cover" />
</>
<Image src={selectedAvatarUrl} alt="Selected avatar" width={96} height={96} className="w-full h-full object-cover" />
);
})()}
</div>
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<p className="text-xs text-gray-500 mt-2 text-center">Your avatar</p>
</div>
</div>
</Surface>
<Text size="xs" color="text-gray-500" mt={2} align="center" block>Your avatar</Text>
</Stack>
</Stack>
{errors.facePhoto && (
<p className="mt-2 text-sm text-red-400">{errors.facePhoto}</p>
<Text size="sm" color="text-error-red" block mt={2}>{errors.facePhoto}</Text>
)}
</div>
</Box>
{/* Suit Color Selection */}
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<div>
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<label className="block text-sm font-medium text-gray-300 mb-3 flex items-center gap-2">
<Palette className="w-4 h-4" />
Racing Suit Color
</label>
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<div className="flex flex-wrap gap-2">
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={3}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Palette} size={4} />
Racing Suit Color
</Stack>
</Text>
<Stack direction="row" wrap gap={2}>
{SUIT_COLORS.map((color) => (
<button
<Button
key={color.value}
type="button"
onClick={() => setAvatarInfo({ ...avatarInfo, suitColor: color.value })}
className={`relative w-10 h-10 rounded-lg transition-all ${
className={`relative w-10 h-10 rounded-lg transition-all p-0 ${
avatarInfo.suitColor === color.value
? 'ring-2 ring-primary-blue ring-offset-2 ring-offset-iron-gray scale-110'
: 'hover:scale-105'
@@ -223,79 +218,66 @@ export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGen
title={color.label}
>
{avatarInfo.suitColor === color.value && (
<Check className={`absolute inset-0 m-auto w-5 h-5 ${
<Icon icon={Check} size={5} className={
['white', 'yellow', 'cyan'].includes(color.value) ? 'text-gray-800' : 'text-white'
}`} />
} />
)}
</button>
</Button>
))}
</div>
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<p className="mt-2 text-xs text-gray-500">
</Stack>
<Text size="xs" color="text-gray-500" block mt={2}>
Selected: {SUIT_COLORS.find(c => c.value === avatarInfo.suitColor)?.label}
</p>
</div>
</Text>
</Box>
{/* Generate Button */}
{avatarInfo.facePhoto && !errors.facePhoto && (
<div>
<Box>
<Button
type="button"
variant="primary"
onClick={onGenerateAvatars}
disabled={avatarInfo.isGenerating || avatarInfo.isValidating}
className="w-full flex items-center justify-center gap-2"
fullWidth
icon={avatarInfo.isGenerating ? <Icon icon={Loader2} size={5} className="animate-spin" /> : <Icon icon={Sparkles} size={5} />}
>
{avatarInfo.isGenerating ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Generating your avatars...
</>
) : (
<>
<Sparkles className="w-5 h-5" />
{avatarInfo.generatedAvatars.length > 0 ? 'Regenerate Avatars' : 'Generate Racing Avatars'}
</>
)}
{avatarInfo.isGenerating ? 'Generating your avatars...' : (avatarInfo.generatedAvatars.length > 0 ? 'Regenerate Avatars' : 'Generate Racing Avatars')}
</Button>
</div>
</Box>
)}
{/* Generated Avatars */}
{avatarInfo.generatedAvatars.length > 0 && (
<div>
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<label className="block text-sm font-medium text-gray-300 mb-3">
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={3}>
Choose Your Avatar *
</label>
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
<div className="grid grid-cols-3 gap-4">
</Text>
<Grid cols={3} gap={4}>
{avatarInfo.generatedAvatars.map((url, index) => (
<button
<Button
key={index}
type="button"
onClick={() => setAvatarInfo({ ...avatarInfo, selectedAvatarIndex: index })}
className={`relative aspect-square rounded-xl overflow-hidden border-2 transition-all ${
className={`relative aspect-square rounded-xl overflow-hidden border-2 transition-all p-0 ${
avatarInfo.selectedAvatarIndex === index
? 'border-primary-blue ring-2 ring-primary-blue/30 scale-105'
: 'border-charcoal-outline hover:border-gray-500'
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt={`Avatar option ${index + 1}`} className="w-full h-full object-cover" />
<Image src={url} alt={`Avatar option ${index + 1}`} width={200} height={200} className="w-full h-full object-cover" />
{avatarInfo.selectedAvatarIndex === index && (
<div className="absolute top-2 right-2 w-6 h-6 rounded-full bg-primary-blue flex items-center justify-center">
<Check className="w-4 h-4 text-white" />
</div>
<Box position="absolute" top={2} right={2} width={6} height={6} rounded="full" backgroundColor="primary-blue" display="flex" center>
<Icon icon={Check} size={4} color="text-white" />
</Box>
)}
</button>
</Button>
))}
</div>
</Grid>
{errors.avatar && (
<p className="mt-2 text-sm text-red-400">{errors.avatar}</p>
<Text size="sm" color="text-error-red" block mt={2}>{errors.avatar}</Text>
)}
</div>
</Box>
)}
</div>
</Stack>
);
}
}

View File

@@ -8,7 +8,7 @@ import { OnboardingHeader } from '@/components/onboarding/OnboardingHeader';
import { OnboardingHelpText } from '@/components/onboarding/OnboardingHelpText';
import { OnboardingNavigation } from '@/components/onboarding/OnboardingNavigation';
import { PersonalInfo, PersonalInfoStep } from '@/components/onboarding/PersonalInfoStep';
import Card from '@/ui/Card';
import { Card } from '@/ui/Card';
import { StepIndicator } from '@/ui/StepIndicator';
import { FormEvent, useState } from 'react';
import { AvatarInfo, AvatarStep } from './AvatarStep';
@@ -190,7 +190,7 @@ export function OnboardingWizard({ onCompleted, onCompleteOnboarding, onGenerate
<StepIndicator currentStep={step} />
<Card className="relative overflow-hidden">
<Card position="relative" overflow="hidden">
<OnboardingCardAccent />
<OnboardingForm onSubmit={handleSubmit}>

View File

@@ -1,7 +1,13 @@
import { User, Clock, ChevronRight } from 'lucide-react';
import Input from '@/ui/Input';
import Heading from '@/ui/Heading';
import CountrySelect from '@/ui/CountrySelect';
import { Input } from '@/ui/Input';
import { Heading } from '@/ui/Heading';
import { CountrySelect } from '@/ui/CountrySelect';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Grid } from '@/ui/Grid';
import { Select } from '@/ui/Select';
export interface PersonalInfo {
firstName: string;
@@ -37,115 +43,115 @@ const TIMEZONES = [
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" />
<Stack gap={6}>
<Box>
<Heading level={2} icon={<Icon icon={User} size={5} color="text-primary-blue" />}>
Personal Information
</Heading>
<p className="text-sm text-gray-400">
<Text size="sm" color="text-gray-400" block mt={1}>
Tell us a bit about yourself
</p>
</div>
</Text>
</Box>
<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">
<Grid cols={2} gap={4}>
<Box>
<Text as="label" htmlFor="firstName" size="sm" weight="medium" color="text-gray-300" block mb={2}>
First Name *
</label>
</Text>
<Input
id="firstName"
type="text"
value={personalInfo.firstName}
onChange={(e) =>
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setPersonalInfo({ ...personalInfo, firstName: e.target.value })
}
error={!!errors.firstName}
variant={errors.firstName ? 'error' : 'default'}
errorMessage={errors.firstName}
placeholder="John"
disabled={loading}
/>
</div>
</Box>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-gray-300 mb-2">
<Box>
<Text as="label" htmlFor="lastName" size="sm" weight="medium" color="text-gray-300" block mb={2}>
Last Name *
</label>
</Text>
<Input
id="lastName"
type="text"
value={personalInfo.lastName}
onChange={(e) =>
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setPersonalInfo({ ...personalInfo, lastName: e.target.value })
}
error={!!errors.lastName}
variant={errors.lastName ? 'error' : 'default'}
errorMessage={errors.lastName}
placeholder="Racer"
disabled={loading}
/>
</div>
</div>
</Box>
</Grid>
<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>
<Box>
<Text as="label" htmlFor="displayName" size="sm" weight="medium" color="text-gray-300" block mb={2}>
Display Name * <Text color="text-gray-500" weight="normal">(shown publicly)</Text>
</Text>
<Input
id="displayName"
type="text"
value={personalInfo.displayName}
onChange={(e) =>
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setPersonalInfo({ ...personalInfo, displayName: e.target.value })
}
error={!!errors.displayName}
variant={errors.displayName ? 'error' : 'default'}
errorMessage={errors.displayName}
placeholder="SpeedyRacer42"
disabled={loading}
/>
</div>
</Box>
<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">
<Grid cols={2} gap={4}>
<Box>
<Text as="label" htmlFor="country" size="sm" weight="medium" color="text-gray-300" block mb={2}>
Country *
</label>
</Text>
<CountrySelect
value={personalInfo.country}
onChange={(value) =>
onChange={(value: string) =>
setPersonalInfo({ ...personalInfo, country: value })
}
error={!!errors.country}
errorMessage={errors.country ?? ''}
disabled={loading}
/>
</div>
</Box>
<div>
<label htmlFor="timezone" className="block text-sm font-medium text-gray-300 mb-2">
<Box>
<Text as="label" htmlFor="timezone" size="sm" weight="medium" color="text-gray-300" block 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
</Text>
<Box position="relative">
<Box position="absolute" left={3} top="50%" style={{ transform: 'translateY(-50%)' }} zIndex={10}>
<Icon icon={Clock} size={4} color="text-gray-500" />
</Box>
<Select
id="timezone"
value={personalInfo.timezone}
onChange={(e) =>
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
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"
options={[
{ value: '', label: 'Select timezone' },
...TIMEZONES
]}
className="pl-10"
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>
/>
<Box position="absolute" right={3} top="50%" style={{ transform: 'translateY(-50%)' }} pointerEvents="none">
<Icon icon={ChevronRight} size={4} color="text-gray-500" className="rotate-90" />
</Box>
</Box>
</Box>
</Grid>
</Stack>
);
}
}

View File

@@ -10,13 +10,14 @@ import { Text } from '@/ui/Text';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { Grid } from '@/ui/Grid';
import { AchievementDisplay } from '@/lib/display-objects/AchievementDisplay';
interface Achievement {
id: string;
title: string;
description: string;
icon: string;
rarity: string;
rarity: 'common' | 'rare' | 'epic' | 'legendary' | string;
earnedAt: Date;
}
@@ -50,28 +51,31 @@ export function AchievementGrid({ achievements }: AchievementGridProps) {
<Grid cols={1} gap={4}>
{achievements.map((achievement) => {
const AchievementIcon = getAchievementIcon(achievement.icon);
const rarity = AchievementDisplay.getRarityColor(achievement.rarity);
return (
<Surface
key={achievement.id}
variant="muted"
variant={rarity.surface}
rounded="xl"
border
padding={4}
>
<Stack direction="row" align="start" gap={3}>
<Surface variant="muted" rounded="lg" padding={3}>
<Icon icon={AchievementIcon} size={5} color="#facc15" />
<Icon icon={AchievementIcon} size={5} color={rarity.icon} />
</Surface>
<Box>
<Text weight="semibold" size="sm" color="text-white" block>{achievement.title}</Text>
<Text size="xs" color="text-gray-400" block mt={1}>{achievement.description}</Text>
<Text size="xs" color="text-gray-500" block mt={2}>
{achievement.earnedAt.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</Text>
<Stack direction="row" align="center" gap={2} mt={2}>
<Text size="xs" color={rarity.text} weight="medium">
{achievement.rarity.toUpperCase()}
</Text>
<Text size="xs" color="text-gray-500"></Text>
<Text size="xs" color="text-gray-500">
{AchievementDisplay.formatDate(achievement.earnedAt)}
</Text>
</Stack>
</Box>
</Stack>
</Surface>

View File

@@ -5,7 +5,10 @@ import Image from 'next/image';
import Link from 'next/link';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import DriverRating from '@/components/profile/DriverRatingPill';
import PlaceholderImage from '@/ui/PlaceholderImage';
import { PlaceholderImage } from '@/ui/PlaceholderImage';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
export interface DriverSummaryPillProps {
driver: DriverViewModel;
@@ -16,14 +19,14 @@ export interface DriverSummaryPillProps {
href?: string;
}
export default function DriverSummaryPill(props: DriverSummaryPillProps) {
export function DriverSummaryPill(props: DriverSummaryPillProps) {
const { driver, rating, rank, avatarSrc, onClick, href } = props;
const resolvedAvatar = avatarSrc;
const content = (
<>
<div className="w-8 h-8 rounded-full overflow-hidden bg-charcoal-outline flex items-center justify-center border border-charcoal-outline/80">
<Box width={8} height={8} rounded="full" className="overflow-hidden bg-charcoal-outline flex items-center justify-center border border-charcoal-outline/80">
{resolvedAvatar ? (
<Image
src={resolvedAvatar}
@@ -35,23 +38,25 @@ export default function DriverSummaryPill(props: DriverSummaryPillProps) {
) : (
<PlaceholderImage size={32} />
)}
</div>
</Box>
<div className="flex flex-col leading-tight text-left">
<span className="text-xs font-semibold text-white truncate max-w-[140px]">
<Stack direction="col" align="start" justify="center" className="leading-tight">
<Text size="xs" weight="semibold" color="text-white" className="truncate max-w-[140px]" block>
{driver.name}
</span>
</Text>
<DriverRating rating={rating} rank={rank} />
</div>
</Stack>
</>
);
const baseClasses = "flex items-center gap-3 rounded-full bg-iron-gray/70 px-3 py-1.5 border border-charcoal-outline/80 shadow-[0_0_18px_rgba(0,0,0,0.45)] hover:border-primary-blue/60 hover:bg-iron-gray transition-colors";
if (href) {
return (
<Link
href={href}
className="flex items-center gap-3 rounded-full bg-iron-gray/70 px-3 py-1.5 border border-charcoal-outline/80 shadow-[0_0_18px_rgba(0,0,0,0.45)] hover:border-primary-blue/60 hover:bg-iron-gray transition-colors"
className={baseClasses}
>
{content}
</Link>
@@ -63,7 +68,7 @@ export default function DriverSummaryPill(props: DriverSummaryPillProps) {
<button
type="button"
onClick={onClick}
className="flex items-center gap-3 rounded-full bg-iron-gray/70 px-3 py-1.5 border border-charcoal-outline/80 shadow-[0_0_18px_rgba(0,0,0,0.45)] hover:border-primary-blue/60 hover:bg-iron-gray transition-colors"
className={baseClasses}
>
{content}
</button>
@@ -71,8 +76,8 @@ export default function DriverSummaryPill(props: DriverSummaryPillProps) {
}
return (
<div className="flex items-center gap-3 rounded-full bg-iron-gray/70 px-3 py-1.5 border border-charcoal-outline/80">
<Box className="flex items-center gap-3 rounded-full bg-iron-gray/70 px-3 py-1.5 border border-charcoal-outline/80">
{content}
</div>
</Box>
);
}

View File

@@ -1,6 +1,12 @@
import Card from '@/ui/Card';
import Button from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import { Car, Download, Trash2, Edit } from 'lucide-react';
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 { Icon } from '@/ui/Icon';
interface DriverLiveryItem {
id: string;
@@ -18,59 +24,64 @@ interface LiveryCardProps {
onDelete?: (id: string) => void;
}
export default function LiveryCard({ livery, onEdit, onDownload, onDelete }: LiveryCardProps) {
export function LiveryCard({ livery, onEdit, onDownload, onDelete }: LiveryCardProps) {
return (
<Card className="overflow-hidden hover:border-primary-blue/50 transition-colors">
{/* Livery Preview */}
<div className="aspect-video bg-deep-graphite rounded-lg mb-4 flex items-center justify-center border border-charcoal-outline">
<Car className="w-16 h-16 text-gray-600" />
</div>
<Box height={48} backgroundColor="deep-graphite" rounded="lg" mb={4} display="flex" center border borderColor="charcoal-outline">
<Icon icon={Car} size={16} color="text-gray-600" />
</Box>
{/* Livery Info */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-white">{livery.carName}</h3>
<Stack gap={3}>
<Stack direction="row" align="center" justify="between">
<Heading level={3}>{livery.carName}</Heading>
{livery.isValidated ? (
<span className="px-2 py-0.5 text-xs bg-performance-green/10 text-performance-green border border-performance-green/30 rounded-full">
<Badge variant="success">
Validated
</span>
</Badge>
) : (
<span className="px-2 py-0.5 text-xs bg-warning-amber/10 text-warning-amber border border-warning-amber/30 rounded-full">
<Badge variant="warning">
Pending
</span>
</Badge>
)}
</div>
</Stack>
<p className="text-xs text-gray-500">
<Text size="xs" color="text-gray-500">
Uploaded {new Date(livery.uploadedAt).toLocaleDateString()}
</p>
</Text>
{/* Actions */}
<div className="flex gap-2 pt-2">
<Stack direction="row" gap={2} pt={2}>
<Button
variant="secondary"
className="flex-1 px-3 py-1.5"
size="sm"
fullWidth
onClick={() => onEdit?.(livery.id)}
icon={<Icon icon={Edit} size={4} />}
>
<Edit className="w-4 h-4 mr-1" />
Edit
</Button>
<Button
variant="secondary"
className="px-3 py-1.5"
size="sm"
onClick={() => onDownload?.(livery.id)}
icon={<Icon icon={Download} size={4} />}
aria-label="Download"
>
<Download className="w-4 h-4" />
{null}
</Button>
<Button
variant="danger"
className="px-3 py-1.5"
size="sm"
onClick={() => onDelete?.(livery.id)}
icon={<Icon icon={Trash2} size={4} />}
aria-label="Delete"
>
<Trash2 className="w-4 h-4" />
{null}
</Button>
</div>
</div>
</Stack>
</Stack>
</Card>
);
}
}

View File

@@ -10,8 +10,8 @@ import { Text } from '@/ui/Text';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { Icon } from '@/ui/Icon';
import { CircularProgress } from '@/components/drivers/CircularProgress';
import { HorizontalBarChart } from '@/components/drivers/HorizontalBarChart';
import { CircularProgress } from '@/components/charts/CircularProgress';
import { HorizontalBarChart } from '@/components/charts/HorizontalBarChart';
interface PerformanceOverviewProps {
stats: {

View File

@@ -1,13 +1,13 @@
'use client';
import React from 'react';
import { User, BarChart3 } from 'lucide-react';
import { User, BarChart3, TrendingUp } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
type ProfileTab = 'overview' | 'stats';
export type ProfileTab = 'overview' | 'stats' | 'ratings';
interface ProfileTabsProps {
activeTab: ProfileTab;
@@ -34,6 +34,14 @@ export function ProfileTabs({ activeTab, onTabChange }: ProfileTabsProps) {
>
Detailed Stats
</Button>
<Button
variant={activeTab === 'ratings' ? 'primary' : 'ghost'}
onClick={() => onTabChange('ratings')}
size="sm"
icon={<Icon icon={TrendingUp} size={4} />}
>
Ratings
</Button>
</Box>
</Surface>
);

View File

@@ -1,5 +1,9 @@
import Card from '@/ui/Card';
import Button from '@/ui/Button';
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';
type UpcomingRace = {
id: string;
@@ -12,44 +16,46 @@ interface UpcomingRacesSidebarProps {
races: UpcomingRace[];
}
export default function UpcomingRacesSidebar({ races }: UpcomingRacesSidebarProps) {
export function UpcomingRacesSidebar({ races }: UpcomingRacesSidebarProps) {
if (!races.length) {
return null;
}
return (
<Card className="bg-iron-gray/80">
<div className="flex items-baseline justify-between mb-3">
<h3 className="text-sm font-semibold text-white">Upcoming races</h3>
<Stack direction="row" align="baseline" justify="between" mb={3}>
<Heading level={3}>Upcoming races</Heading>
<Button
as="a"
href="/races"
variant="secondary"
className="text-[11px] px-3 py-1.5"
size="sm"
>
View all
</Button>
</div>
<ul className="space-y-3">
</Stack>
<Stack gap={3}>
{races.slice(0, 4).map((race) => {
const scheduledAt = typeof race.scheduledAt === 'string' ? new Date(race.scheduledAt) : race.scheduledAt;
return (
<li key={race.id} className="flex items-start justify-between gap-3 text-xs">
<div className="flex-1 min-w-0">
<p className="text-white truncate">{race.track}</p>
<p className="text-gray-400 truncate">{race.car}</p>
</div>
<div className="text-right text-gray-500 whitespace-nowrap">
{scheduledAt.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric'
})}
</div>
</li>
<Box key={race.id} display="flex" justify="between" gap={3}>
<Box flex={1} className="min-w-0">
<Text size="xs" color="text-white" block className="truncate">{race.track}</Text>
<Text size="xs" color="text-gray-400" block className="truncate">{race.car}</Text>
</Box>
<Box textAlign="right">
<Text size="xs" color="text-gray-500" className="whitespace-nowrap">
{scheduledAt.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric'
})}
</Text>
</Box>
</Box>
);
})}
</ul>
</Stack>
</Card>
);
}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
import Heading from '@/ui/Heading';
import Button from '@/ui/Button';
import { Heading } from '@/ui/Heading';
import { Button } from '@/ui/Button';
interface HeroSectionProps {
title: string;
@@ -9,14 +9,18 @@ interface HeroSectionProps {
icon?: LucideIcon;
backgroundPattern?: React.ReactNode;
stats?: Array<{
icon: LucideIcon;
icon?: LucideIcon;
value: string | number;
label: string;
color?: string;
animate?: boolean;
}>;
actions?: Array<{
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary';
icon?: LucideIcon;
description?: string;
}>;
children?: React.ReactNode;
className?: string;
@@ -32,15 +36,16 @@ export const HeroSection = ({
children,
className = ''
}: HeroSectionProps) => (
<section className={`relative overflow-hidden ${className}`}>
<section className={`relative overflow-hidden rounded-2xl bg-gradient-to-br from-iron-gray/80 via-deep-graphite to-iron-gray/60 border border-charcoal-outline/50 ${className}`}>
{/* Background Pattern */}
{backgroundPattern && (
<div className="absolute inset-0">
{backgroundPattern}
</div>
{backgroundPattern || (
<>
<div className="absolute top-0 right-0 w-96 h-96 bg-primary-blue/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-64 h-64 bg-neon-aqua/5 rounded-full blur-3xl" />
</>
)}
<div className="relative max-w-7xl mx-auto px-6 py-10">
<div className="relative max-w-7xl mx-auto px-8 py-10">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8">
{/* Main Content */}
<div className="max-w-2xl">
@@ -70,7 +75,11 @@ export const HeroSection = ({
<div className="flex flex-wrap gap-6 mt-6">
{stats.map((stat, index) => (
<div key={index} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-primary-blue animate-pulse" />
{stat.icon ? (
<stat.icon className={`w-4 h-4 ${stat.color || 'text-primary-blue'}`} />
) : (
<div className={`w-2 h-2 rounded-full ${stat.color || 'bg-primary-blue'} ${stat.animate ? 'animate-pulse' : ''}`} />
)}
<span className="text-sm text-gray-400">
<span className="text-white font-semibold">{stat.value}</span> {stat.label}
</span>
@@ -84,14 +93,19 @@ export const HeroSection = ({
{actions && actions.length > 0 && (
<div className="flex flex-col gap-4">
{actions.map((action, index) => (
<Button
key={index}
variant={action.variant || 'primary'}
onClick={action.onClick}
className="flex items-center gap-2 px-6 py-3"
>
{action.label}
</Button>
<div key={index} className="flex flex-col gap-2">
<Button
variant={action.variant || 'primary'}
onClick={action.onClick}
className="flex items-center gap-2 px-6 py-3"
>
{action.icon && <action.icon className="w-5 h-5" />}
{action.label}
</Button>
{action.description && (
<p className="text-xs text-gray-500 text-center">{action.description}</p>
)}
</div>
))}
</div>
)}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
import { StatusBadge as UIStatusBadge } from '@/ui/StatusBadge';
interface StatusBadgeProps {
status: string;
@@ -16,51 +17,42 @@ interface StatusBadgeProps {
export const StatusBadge = ({ status, config, className = '' }: StatusBadgeProps) => {
const defaultConfig = {
scheduled: {
icon: () => null,
color: 'text-primary-blue',
bg: 'bg-primary-blue/10',
border: 'border-primary-blue/30',
icon: undefined,
variant: 'info' as const,
label: 'Scheduled',
},
running: {
icon: () => null,
color: 'text-performance-green',
bg: 'bg-performance-green/10',
border: 'border-performance-green/30',
icon: undefined,
variant: 'success' as const,
label: 'LIVE',
},
completed: {
icon: () => null,
color: 'text-gray-400',
bg: 'bg-gray-500/10',
border: 'border-gray-500/30',
icon: undefined,
variant: 'neutral' as const,
label: 'Completed',
},
cancelled: {
icon: () => null,
color: 'text-warning-amber',
bg: 'bg-warning-amber/10',
border: 'border-warning-amber/30',
icon: undefined,
variant: 'warning' as const,
label: 'Cancelled',
},
};
const badgeConfig = config || defaultConfig[status as keyof typeof defaultConfig] || {
icon: () => null,
color: 'text-gray-400',
bg: 'bg-gray-500/10',
border: 'border-gray-500/30',
label: status,
};
const Icon = badgeConfig.icon;
const badgeConfig = config
? { ...config, variant: 'info' as const } // Fallback variant if config is provided
: defaultConfig[status as keyof typeof defaultConfig] || {
icon: undefined,
variant: 'neutral' as const,
label: status,
};
return (
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full ${badgeConfig.bg} ${badgeConfig.border} border ${className}`}>
{Icon && <Icon className={`w-3.5 h-3.5 ${badgeConfig.color}`} />}
<span className={`text-xs font-medium ${badgeConfig.color}`}>
{badgeConfig.label}
</span>
</div>
<UIStatusBadge
variant={badgeConfig.variant}
icon={badgeConfig.icon}
className={className}
>
{badgeConfig.label}
</UIStatusBadge>
);
};

View File

@@ -2,7 +2,7 @@
import React from 'react';
import { EmptyStateProps, EmptyStateAction } from './types';
import Button from '@/ui/Button';
import { Button } from '@/ui/Button';
// Illustration components (simple SVG representations)
const Illustrations = {

View File

@@ -1,44 +0,0 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { getMediaUrl } from '@/lib/utilities/media';
interface Friend {
id: string;
name: string;
country: string;
}
interface FriendPillProps {
friend: Friend;
}
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 '🏁';
}
export default function FriendPill({ friend }: FriendPillProps) {
return (
<Link
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={getMediaUrl('driver-avatar', friend.id)}
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>
);
}

View File

@@ -1,63 +0,0 @@
import React from 'react';
import { MessageCircle, Twitter, Youtube, Twitch } from 'lucide-react';
import type { DriverProfileSocialHandleViewModel } from '@/lib/view-models/DriverProfileViewModel';
interface SocialHandlesProps {
socialHandles: DriverProfileSocialHandleViewModel[];
}
function getSocialIcon(platform: DriverProfileSocialHandleViewModel['platform']) {
switch (platform) {
case 'twitter':
return Twitter;
case 'youtube':
return Youtube;
case 'twitch':
return Twitch;
case 'discord':
return MessageCircle;
}
}
function getSocialColor(platform: DriverProfileSocialHandleViewModel['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 default function SocialHandles({ socialHandles }: SocialHandlesProps) {
if (socialHandles.length === 0) return null;
return (
<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>
{socialHandles.map((social) => {
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>
<svg className="w-3 h-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
);
})}
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { Medal, Users, Globe, Languages } from 'lucide-react';
import { Medal, Users } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
@@ -32,66 +32,56 @@ interface TeamRankingsTableProps {
export function TeamRankingsTable({ teams, sortBy, onTeamClick }: TeamRankingsTableProps) {
return (
<Box style={{ borderRadius: '0.75rem', backgroundColor: 'rgba(38, 38, 38, 0.3)', border: '1px solid #262626', overflow: 'hidden' }}>
<Surface variant="muted" rounded="xl" border className="overflow-hidden">
{/* Table Header */}
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(12, minmax(0, 1fr))', gap: '1rem', padding: '0.75rem 1rem', backgroundColor: 'rgba(38, 38, 38, 0.5)', borderBottom: '1px solid #262626', fontSize: '0.75rem', fontWeight: 500, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
<Box style={{ gridColumn: 'span 1', textAlign: 'center' }}>Rank</Box>
<Box style={{ gridColumn: 'span 5' }}>Team</Box>
<Box style={{ gridColumn: 'span 2', textAlign: 'center' }} className="hidden lg:block">Members</Box>
<Box style={{ gridColumn: 'span 2', textAlign: 'center' }}>Rating</Box>
<Box style={{ gridColumn: 'span 2', textAlign: 'center' }}>Wins</Box>
<Box display="grid" className="grid-cols-12 gap-4 px-4 py-3 bg-iron-gray/50 border-b border-charcoal-outline text-[10px] font-medium text-gray-500 uppercase tracking-wider">
<Box className="col-span-1 text-center">Rank</Box>
<Box className="col-span-5">Team</Box>
<Box className="col-span-2 text-center hidden lg:block">Members</Box>
<Box className="col-span-2 text-center">Rating</Box>
<Box className="col-span-2 text-center">Wins</Box>
</Box>
{/* Table Body */}
<Stack gap={0}>
{teams.map((team, index) => {
const winRate = team.totalRaces > 0 ? ((team.totalWins / team.totalRaces) * 100).toFixed(1) : '0.0';
return (
<Box
key={team.id}
as="button"
type="button"
onClick={() => onTeamClick(team.id)}
style={{
display: 'grid',
gridTemplateColumns: 'repeat(12, minmax(0, 1fr))',
gap: '1rem',
padding: '1rem',
width: '100%',
textAlign: 'left',
backgroundColor: 'transparent',
border: 'none',
cursor: 'pointer',
borderBottom: index < teams.length - 1 ? '1px solid rgba(38, 38, 38, 0.5)' : 'none'
}}
display="grid"
className={`grid-cols-12 gap-4 p-4 w-full text-left bg-transparent border-0 cursor-pointer hover:bg-iron-gray/20 transition-colors ${
index < teams.length - 1 ? 'border-b border-charcoal-outline/50' : ''
}`}
>
{/* Position */}
<Box style={{ gridColumn: 'span 1', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Surface variant="muted" rounded="full" padding={1} style={{ width: '2.25rem', height: '2.25rem', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#262626' }}>
{index < 3 ? <Icon icon={Medal} size={4} /> : index + 1}
</Surface>
<Box className="col-span-1 flex items-center justify-center">
<Box width={9} height={9} rounded="full" display="flex" center backgroundColor="charcoal-outline">
{index < 3 ? <Icon icon={Medal} size={4} color={index === 0 ? 'text-yellow-400' : index === 1 ? 'text-gray-300' : 'text-amber-600'} /> : <Text size="xs" color="text-gray-400">{index + 1}</Text>}
</Box>
</Box>
{/* Team Info */}
<Box style={{ gridColumn: 'span 5', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<Box style={{ width: '2.5rem', height: '2.5rem', borderRadius: '0.5rem', overflow: 'hidden', border: '1px solid #262626' }}>
<Box className="col-span-5 flex items-center gap-3">
<Box width={10} height={10} rounded="lg" className="overflow-hidden" border borderColor="charcoal-outline">
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={40}
height={40}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
className="w-full h-full object-cover"
/>
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text weight="semibold" color="text-white" block truncate>{team.name}</Text>
<Box className="min-w-0 flex-1">
<Text weight="semibold" color="text-white" block className="truncate">{team.name}</Text>
<Stack direction="row" align="center" gap={2} mt={1} wrap>
<Text size="xs" color="text-gray-500">{team.performanceLevel}</Text>
{team.category && (
<Stack direction="row" align="center" gap={1}>
<Box style={{ width: '0.375rem', height: '0.375rem', borderRadius: '9999px', backgroundColor: '#a855f7' }} />
<Text size="xs" color="text-purple-400">{team.category}</Text>
<Box width={1.5} height={1.5} rounded="full" backgroundColor="primary-blue" opacity={0.5} />
<Text size="xs" color="text-primary-blue">{team.category}</Text>
</Stack>
)}
</Stack>
@@ -99,22 +89,22 @@ export function TeamRankingsTable({ teams, sortBy, onTeamClick }: TeamRankingsTa
</Box>
{/* Members */}
<Box style={{ gridColumn: 'span 2', display: 'flex', alignItems: 'center', justifyContent: 'center' }} className="hidden lg:flex">
<Box className="col-span-2 flex items-center justify-center hidden lg:flex">
<Stack direction="row" align="center" gap={1.5}>
<Icon icon={Users} size={3.5} color="#9ca3af" />
<Icon icon={Users} size={3.5} color="text-gray-500" />
<Text size="sm" color="text-gray-400">{team.memberCount}</Text>
</Stack>
</Box>
{/* Rating */}
<Box style={{ gridColumn: 'span 2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box className="col-span-2 flex items-center justify-center">
<Text font="mono" weight="semibold" color={sortBy === 'rating' ? 'text-primary-blue' : 'text-white'}>
0
</Text>
</Box>
{/* Wins */}
<Box style={{ gridColumn: 'span 2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box className="col-span-2 flex items-center justify-center">
<Text font="mono" weight="semibold" color={sortBy === 'wins' ? 'text-primary-blue' : 'text-white'}>
{team.totalWins}
</Text>
@@ -123,6 +113,6 @@ export function TeamRankingsTable({ teams, sortBy, onTeamClick }: TeamRankingsTa
);
})}
</Stack>
</Box>
</Surface>
);
}

View File

@@ -1,10 +1,18 @@
'use client';
import Card from '@/ui/Card';
import { Card } from '@/ui/Card';
import { DriverIdentity } from '@/components/drivers/DriverIdentity';
import { useTeamRoster } from "@/lib/hooks/team";
import { useState } from 'react';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Select } from '@/ui/Select';
import { Surface } from '@/ui/Surface';
import { Badge } from '@/ui/Badge';
import { Button } from '@/ui/Button';
type TeamRole = 'owner' | 'admin' | 'member';
type TeamMemberRole = 'owner' | 'manager' | 'member';
@@ -24,7 +32,7 @@ interface TeamRosterProps {
onChangeRole?: (driverId: string, newRole: TeamRole) => void;
}
export default function TeamRoster({
export function TeamRoster({
teamId,
memberships,
isAdmin,
@@ -36,17 +44,6 @@ export default function TeamRoster({
// Use hook for data fetching
const { data: teamMembers = [], isLoading: loading } = useTeamRoster(memberships);
const getRoleBadgeColor = (role: TeamRole) => {
switch (role) {
case 'owner':
return 'bg-warning-amber/20 text-warning-amber';
case 'admin':
return 'bg-primary-blue/20 text-primary-blue';
default:
return 'bg-charcoal-outline text-gray-300';
}
};
const getRoleLabel = (role: TeamRole | TeamMemberRole) => {
// Convert manager to admin for display
const displayRole = role === 'manager' ? 'admin' : role;
@@ -85,43 +82,48 @@ export default function TeamRoster({
});
const teamAverageRating = teamMembers.length > 0
? teamMembers.reduce((sum, m) => sum + (m.rating || 0), 0) / teamMembers.length
? teamMembers.reduce((sum: number, m: any) => sum + (m.rating || 0), 0) / teamMembers.length
: 0;
if (loading) {
return (
<Card>
<div className="text-center py-8 text-gray-400">Loading roster...</div>
<Box textAlign="center" py={8}>
<Text color="text-gray-400">Loading roster...</Text>
</Box>
</Card>
);
}
return (
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-xl font-semibold text-white">Team Roster</h3>
<p className="text-sm text-gray-400 mt-1">
<Stack direction="row" align="center" justify="between" mb={6} wrap>
<Box>
<Heading level={3}>Team Roster</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
{memberships.length} {memberships.length === 1 ? 'member' : 'members'} Avg Rating:{' '}
<span className="text-primary-blue font-medium">{teamAverageRating}</span>
</p>
</div>
<Text color="text-primary-blue" weight="medium">{teamAverageRating.toFixed(0)}</Text>
</Text>
</Box>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-400">Sort by:</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
>
<option value="rating">Rating</option>
<option value="role">Role</option>
<option value="name">Name</option>
</select>
</div>
</div>
<Stack direction="row" align="center" gap={2}>
<Text size="sm" color="text-gray-400">Sort by:</Text>
<Box width={32}>
<Select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
options={[
{ value: 'rating', label: 'Rating' },
{ value: 'role', label: 'Role' },
{ value: 'name', label: 'Name' },
]}
className="py-1 text-sm"
/>
</Box>
</Stack>
</Stack>
<div className="space-y-3">
<Stack gap={3}>
{sortedMembers.map((member) => {
const { driver, role, joinedAt, rating, overallRank } = member;
@@ -130,68 +132,79 @@ export default function TeamRoster({
const canManageMembership = isAdmin && role !== 'owner';
return (
<div
<Surface
key={driver.id}
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-charcoal-outline/60 transition-colors"
variant="dark"
rounded="lg"
border
padding={4}
>
<DriverIdentity
driver={driver as DriverViewModel}
href={`/drivers/${driver.id}?from=team&teamId=${teamId}`}
contextLabel={getRoleLabel(role)}
meta={
<span>
{driver.country} Joined {new Date(joinedAt).toLocaleDateString()}
</span>
}
size="md"
/>
<Stack direction="row" align="center" justify="between" wrap gap={4}>
<DriverIdentity
driver={driver as DriverViewModel}
href={`/drivers/${driver.id}?from=team&teamId=${teamId}`}
contextLabel={getRoleLabel(role)}
meta={
<Text size="xs" color="text-gray-400">
{driver.country} Joined {new Date(joinedAt).toLocaleDateString()}
</Text>
}
size="md"
/>
{rating !== null && (
<div className="flex items-center gap-6 text-center">
<div>
<div className="text-lg font-bold text-primary-blue">
{rating}
</div>
<div className="text-xs text-gray-400">Rating</div>
</div>
{overallRank !== null && (
<div>
<div className="text-sm text-gray-300">#{overallRank}</div>
<div className="text-xs text-gray-500">Rank</div>
</div>
)}
</div>
)}
{rating !== null && (
<Stack direction="row" align="center" gap={6}>
<Box textAlign="center">
<Text size="lg" weight="bold" color="text-primary-blue" block>
{rating}
</Text>
<Text size="xs" color="text-gray-400">Rating</Text>
</Box>
{overallRank !== null && (
<Box textAlign="center">
<Text size="sm" color="text-gray-300" block>#{overallRank}</Text>
<Text size="xs" color="text-gray-500">Rank</Text>
</Box>
)}
</Stack>
)}
{canManageMembership && (
<div className="flex items-center gap-2">
<select
className="px-3 py-2 bg-iron-gray border-0 rounded text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
value={displayRole}
onChange={(e) =>
onChangeRole?.(driver.id, e.target.value as TeamRole)
}
>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
{canManageMembership && (
<Stack direction="row" align="center" gap={2}>
<Box width={32}>
<Select
value={displayRole}
onChange={(e) =>
onChangeRole?.(driver.id, e.target.value as TeamRole)
}
options={[
{ value: 'member', label: 'Member' },
{ value: 'admin', label: 'Admin' },
]}
className="text-sm"
/>
</Box>
<button
onClick={() => onRemoveMember?.(driver.id)}
className="px-3 py-2 bg-danger-red/20 hover:bg-danger-red/30 text-danger-red rounded text-sm font-medium transition-colors"
>
Remove
</button>
</div>
)}
</div>
<Button
variant="danger"
size="sm"
onClick={() => onRemoveMember?.(driver.id)}
>
Remove
</Button>
</Stack>
)}
</Stack>
</Surface>
);
})}
</div>
</Stack>
{memberships.length === 0 && (
<div className="text-center py-8 text-gray-400">No team members yet.</div>
<Box textAlign="center" py={8}>
<Text color="text-gray-400">No team members yet.</Text>
</Box>
)}
</Card>
);
}
}

View File

@@ -1,28 +1,30 @@
'use client';
import { Search } from 'lucide-react';
import Input from '@/ui/Input';
import { Input } from '@/ui/Input';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
interface TeamSearchBarProps {
searchQuery: string;
onSearchChange: (query: string) => void;
}
export default function TeamSearchBar({ searchQuery, onSearchChange }: TeamSearchBarProps) {
export function TeamSearchBar({ searchQuery, onSearchChange }: TeamSearchBarProps) {
return (
<div id="teams-list" className="mb-6 scroll-mt-8">
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<Box id="teams-list" mb={6} className="scroll-mt-8">
<Stack direction="row" gap={4} wrap>
<Box flex={1}>
<Input
type="text"
placeholder="Search teams by name, description, region, or language..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-11"
icon={<Icon icon={Search} size={5} color="text-gray-500" />}
/>
</div>
</div>
</div>
</Box>
</Stack>
</Box>
);
}
}

View File

@@ -1,64 +1,90 @@
'use client';
import Card from '@/ui/Card';
import { Card } from '@/ui/Card';
import { useTeamStandings } from "@/lib/hooks/team";
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Badge } from '@/ui/Badge';
import { Grid } from '@/ui/Grid';
import { Surface } from '@/ui/Surface';
interface TeamStandingsProps {
teamId: string;
leagues: string[];
}
export default function TeamStandings({ teamId, leagues }: TeamStandingsProps) {
export function TeamStandings({ teamId, leagues }: TeamStandingsProps) {
const { data: standings = [], isLoading: loading } = useTeamStandings(teamId, leagues);
if (loading) {
return (
<Card>
<div className="text-center py-8 text-gray-400">Loading standings...</div>
<Box textAlign="center" py={8}>
<Text color="text-gray-400">Loading standings...</Text>
</Box>
</Card>
);
}
return (
<Card>
<h3 className="text-xl font-semibold text-white mb-6">League Standings</h3>
<Box mb={6}>
<Heading level={2}>
League Standings
</Heading>
</Box>
<div className="space-y-4">
<Stack gap={4}>
{standings.map((standing: any) => (
<div
<Surface
key={standing.leagueId}
className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
variant="dark"
rounded="lg"
border
padding={4}
>
<div className="flex items-center justify-between mb-3">
<h4 className="text-white font-medium">{standing.leagueName}</h4>
<span className="px-3 py-1 bg-primary-blue/20 text-primary-blue rounded-full text-sm font-semibold">
<Stack direction="row" align="center" justify="between" mb={3}>
<Heading level={4}>
{standing.leagueName}
</Heading>
<Badge variant="primary">
P{standing.position}
</span>
</div>
</Badge>
</Stack>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-white">{standing.points}</div>
<div className="text-xs text-gray-400">Points</div>
</div>
<div>
<div className="text-2xl font-bold text-white">{standing.wins}</div>
<div className="text-xs text-gray-400">Wins</div>
</div>
<div>
<div className="text-2xl font-bold text-white">{standing.racesCompleted}</div>
<div className="text-xs text-gray-400">Races</div>
</div>
</div>
</div>
<Grid cols={3} gap={4}>
<Box textAlign="center">
<Text size="2xl" weight="bold" color="text-white" block>
{standing.points}
</Text>
<Text size="xs" color="text-gray-400">Points</Text>
</Box>
<Box textAlign="center">
<Text size="2xl" weight="bold" color="text-white" block>
{standing.wins}
</Text>
<Text size="xs" color="text-gray-400">Wins</Text>
</Box>
<Box textAlign="center">
<Text size="2xl" weight="bold" color="text-white" block>
{standing.racesCompleted}
</Text>
<Text size="xs" color="text-gray-400">Races</Text>
</Box>
</Grid>
</Surface>
))}
</div>
</Stack>
{standings.length === 0 && (
<div className="text-center py-8 text-gray-400">
No standings available yet.
</div>
<Box textAlign="center" py={8}>
<Text color="text-gray-400">
No standings available yet.
</Text>
</Box>
)}
</Card>
);
}
}

View File

@@ -2,50 +2,20 @@ import Image from 'next/image';
import { Trophy, Crown, Users } from 'lucide-react';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
const SKILL_LEVELS: {
id: string;
icon: React.ElementType;
color: string;
bgColor: string;
borderColor: string;
}[] = [
{
id: 'pro',
icon: () => null,
color: 'text-yellow-400',
bgColor: 'bg-yellow-400/10',
borderColor: 'border-yellow-400/30',
},
{
id: 'advanced',
icon: () => null,
color: 'text-purple-400',
bgColor: 'bg-purple-400/10',
borderColor: 'border-purple-400/30',
},
{
id: 'intermediate',
icon: () => null,
color: 'text-primary-blue',
bgColor: 'bg-primary-blue/10',
borderColor: 'border-primary-blue/30',
},
{
id: 'beginner',
icon: () => null,
color: 'text-green-400',
bgColor: 'bg-green-400/10',
borderColor: 'border-green-400/30',
},
];
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 { Button } from '@/ui/Button';
interface TopThreePodiumProps {
teams: TeamSummaryViewModel[];
onClick: (id: string) => void;
}
export default function TopThreePodium({ teams, onClick }: TopThreePodiumProps) {
export function TopThreePodium({ teams, onClick }: TopThreePodiumProps) {
const top3 = teams.slice(0, 3) as [TeamSummaryViewModel, TeamSummaryViewModel, TeamSummaryViewModel];
if (teams.length < 3) return null;
@@ -71,118 +41,120 @@ export default function TopThreePodium({ teams, onClick }: TopThreePodiumProps)
}
};
const getGradient = (position: number) => {
const getVariant = (position: number): any => {
switch (position) {
case 1:
return 'from-yellow-400/30 via-yellow-500/20 to-yellow-600/10';
return 'gradient-gold';
case 2:
return 'from-gray-300/30 via-gray-400/20 to-gray-500/10';
return 'default';
case 3:
return 'from-amber-500/30 via-amber-600/20 to-amber-700/10';
return 'gradient-purple';
default:
return 'from-gray-600/30 to-gray-700/10';
}
};
const getBorderColor = (position: number) => {
switch (position) {
case 1:
return 'border-yellow-400/50';
case 2:
return 'border-gray-300/50';
case 3:
return 'border-amber-600/50';
default:
return 'border-charcoal-outline';
return 'muted';
}
};
return (
<div className="mb-10 p-8 rounded-2xl bg-gradient-to-br from-iron-gray/60 to-iron-gray/30 border border-charcoal-outline">
<div className="flex items-center justify-center gap-2 mb-8">
<Trophy className="w-6 h-6 text-yellow-400" />
<h2 className="text-xl font-bold text-white">Top 3 Teams</h2>
</div>
<Surface variant="muted" rounded="2xl" border padding={8} mb={10}>
<Box display="flex" center mb={8}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Trophy} size={6} color="text-yellow-400" />
<Heading level={2}>Top 3 Teams</Heading>
</Stack>
</Box>
<div className="flex items-end justify-center gap-4 md:gap-8">
<Stack direction="row" align="end" justify="center" gap={8}>
{podiumOrder.map((team, index) => {
const position = podiumPositions[index] ?? 0;
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
return (
<button
key={team.id}
type="button"
onClick={() => onClick(team.id)}
className="flex flex-col items-center group"
>
<Stack key={team.id} align="center">
{/* Team card */}
<div
className={`relative mb-4 p-4 rounded-xl bg-gradient-to-br ${getGradient(position ?? 0)} border ${getBorderColor(position ?? 0)} transition-all group-hover:scale-105 group-hover:shadow-lg`}
<Button
variant="ghost"
onClick={() => onClick(team.id)}
className="p-0 h-auto hover:scale-105 transition-transform"
>
{/* Crown for 1st place */}
{position === 1 && (
<div className="absolute -top-4 left-1/2 -translate-x-1/2">
<div className="relative">
<Crown className="w-8 h-8 text-yellow-400 animate-pulse" />
<div className="absolute inset-0 w-8 h-8 bg-yellow-400/30 blur-md rounded-full" />
</div>
</div>
)}
<Surface
variant={getVariant(position)}
rounded="xl"
border
padding={4}
position="relative"
mb={4}
>
{/* Crown for 1st place */}
{position === 1 && (
<Box position="absolute" top="-4" left="50%" style={{ transform: 'translateX(-50%)' }}>
<Box position="relative">
<Icon icon={Crown} size={8} color="text-yellow-400" className="animate-pulse" />
<Box position="absolute" inset="0" backgroundColor="yellow-400" opacity={0.3} className="blur-md rounded-full" />
</Box>
</Box>
)}
{/* Team logo */}
<div className="flex h-16 w-16 md:h-20 md:w-20 items-center justify-center rounded-xl bg-charcoal-outline border border-charcoal-outline overflow-hidden mb-3">
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={80}
height={80}
className="w-full h-full object-cover"
/>
</div>
{/* Team logo */}
<Box height={20} width={20} display="flex" center rounded="xl" backgroundColor="charcoal-outline" border borderColor="charcoal-outline" className="overflow-hidden" mb={3}>
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={80}
height={80}
className="w-full h-full object-cover"
/>
</Box>
{/* Team name */}
<p className="text-white font-bold text-sm md:text-base text-center max-w-[120px] truncate group-hover:text-purple-400 transition-colors">
{team.name}
</p>
{/* Team name */}
<Text weight="bold" size="sm" color="text-white" align="center" block className="max-w-[120px] truncate group-hover:text-primary-blue transition-colors">
{team.name}
</Text>
{/* Category */}
{team.category && (
<p className="text-xs text-purple-400 text-center mt-1">
{team.category}
</p>
)}
{/* Category */}
{team.category && (
<Text size="xs" color="text-primary-blue" align="center" block mt={1}>
{team.category}
</Text>
)}
{/* Rating */}
<p className={`text-lg md:text-xl font-mono font-bold ${getPositionColor(position)} text-center`}>
{'—'}
</p>
{/* Rating placeholder */}
<Text size="xl" weight="bold" className={`${getPositionColor(position)}`} align="center" block mt={1}>
</Text>
{/* Stats row */}
<div className="flex items-center justify-center gap-3 mt-2 text-xs text-gray-400">
<span className="flex items-center gap-1">
<Trophy className="w-3 h-3 text-performance-green" />
{team.totalWins}
</span>
<span className="flex items-center gap-1">
<Users className="w-3 h-3 text-purple-400" />
{team.memberCount}
</span>
</div>
</div>
{/* Stats row */}
<Stack direction="row" align="center" justify="center" gap={3} mt={2}>
<Stack direction="row" align="center" gap={1}>
<Icon icon={Trophy} size={3} color="text-performance-green" />
<Text size="xs" color="text-gray-400">{team.totalWins}</Text>
</Stack>
<Stack direction="row" align="center" gap={1}>
<Icon icon={Users} size={3} color="text-primary-blue" />
<Text size="xs" color="text-gray-400">{team.memberCount}</Text>
</Stack>
</Stack>
</Surface>
</Button>
{/* Podium stand */}
<div
className={`${podiumHeights[index]} w-20 md:w-28 rounded-t-lg bg-gradient-to-t ${getGradient(position)} border-t border-x ${getBorderColor(position)} flex items-start justify-center pt-3`}
<Surface
variant={getVariant(position)}
rounded="none"
className={`rounded-t-lg ${podiumHeights[index]}`}
border
width="28"
display="flex"
padding={3}
>
<span className={`text-2xl md:text-3xl font-bold ${getPositionColor(position)}`}>
{position}
</span>
</div>
</button>
<Box display="flex" center fullWidth>
<Text size="3xl" weight="bold" className={getPositionColor(position)}>
{position}
</Text>
</Box>
</Surface>
</Stack>
);
})}
</div>
</div>
</Stack>
</Surface>
);
}
}

View File

@@ -3,10 +3,23 @@ import {
MessageCircle,
Calendar,
Trophy,
LucideIcon,
} from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Grid } from '@/ui/Grid';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
export default function WhyJoinTeamSection() {
const benefits = [
interface Benefit {
icon: LucideIcon;
title: string;
description: string;
}
export function WhyJoinTeamSection() {
const benefits: Benefit[] = [
{
icon: Handshake,
title: 'Shared Strategy',
@@ -30,26 +43,33 @@ export default function WhyJoinTeamSection() {
];
return (
<div className="mb-12">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-white mb-2">Why Join a Team?</h2>
<p className="text-gray-400">Racing is better when you have teammates to share the journey</p>
</div>
<Box mb={12}>
<Box textAlign="center" mb={8}>
<Box mb={2}>
<Heading level={2}>Why Join a Team?</Heading>
</Box>
<Text color="text-gray-400">Racing is better when you have teammates to share the journey</Text>
</Box>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Grid cols={4} gap={4}>
{benefits.map((benefit) => (
<div
<Surface
key={benefit.title}
className="p-5 rounded-xl bg-iron-gray/50 border border-charcoal-outline/50 hover:border-purple-500/30 transition-all duration-300"
variant="muted"
rounded="xl"
border
padding={5}
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10 border border-purple-500/20 mb-3">
<benefit.icon className="w-5 h-5 text-purple-400" />
</div>
<h3 className="text-white font-semibold mb-1">{benefit.title}</h3>
<p className="text-sm text-gray-500">{benefit.description}</p>
</div>
<Box height={10} width={10} display="flex" center rounded="lg" backgroundColor="primary-blue" opacity={0.1} border borderColor="primary-blue" mb={3}>
<Icon icon={benefit.icon} size={5} color="text-primary-blue" />
</Box>
<Box mb={1}>
<Heading level={3}>{benefit.title}</Heading>
</Box>
<Text size="sm" color="text-gray-500">{benefit.description}</Text>
</Surface>
))}
</div>
</div>
</Grid>
</Box>
);
}
}

View File

@@ -0,0 +1,21 @@
export class AchievementDisplay {
static getRarityColor(rarity: string) {
switch (rarity.toLowerCase()) {
case 'common':
return { text: 'text-gray-400', surface: 'muted' as const, icon: '#9ca3af' };
case 'rare':
return { text: 'text-primary-blue', surface: 'gradient-blue' as const, icon: '#3b82f6' };
case 'epic':
return { text: 'text-purple-400', surface: 'gradient-purple' as const, icon: '#a855f7' };
case 'legendary':
return { text: 'text-yellow-400', surface: 'gradient-gold' as const, icon: '#facc15' };
default:
return { text: 'text-gray-400', surface: 'muted' as const, icon: '#9ca3af' };
}
}
static formatDate(date: Date): string {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `${months[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
}
}

View File

@@ -10,20 +10,19 @@ 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 { Breadcrumbs } from '@/components/layout/Breadcrumbs';
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 RatingBreakdown from '@/components/drivers/RatingBreakdown';
import { ProfileTabs, type ProfileTab } from '@/components/profile/ProfileTabs';
import type { DriverProfileViewData } from '../../../lib/types/view-data/DriverProfileViewData';
type ProfileTab = 'overview' | 'stats';
interface DriverProfileTemplateProps {
viewData: DriverProfileViewData;
isLoading?: boolean;
@@ -184,6 +183,14 @@ export function DriverProfileTemplate({
<Text size="sm" color="text-gray-500">This driver hasn&apos;t completed any races yet</Text>
</Stack>
)}
{activeTab === 'ratings' && (
<RatingBreakdown
skillRating={stats?.rating || 1450}
safetyRating={92} // Placeholder as not in viewData yet
sportsmanshipRating={4.8} // Placeholder as not in viewData yet
/>
)}
</Stack>
</Container>
);

View File

@@ -19,7 +19,8 @@ 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 { DriversHero } from '@/components/drivers/DriversHero';
import { HeroSection } from '@/components/shared/HeroSection';
import { Users, Trophy } from 'lucide-react';
import { DriversSearch } from '@/components/drivers/DriversSearch';
import { EmptyState } from '@/components/shared/state/EmptyState';
import type { DriversViewData } from '@/lib/types/view-data/DriversViewData';
@@ -53,12 +54,24 @@ export function DriversTemplate({
<Container size="lg" py={8}>
<Stack gap={10}>
{/* Hero Section */}
<DriversHero
driverCount={drivers.length}
activeCount={activeCount}
totalWins={totalWins}
totalRaces={totalRaces}
onViewLeaderboard={onViewLeaderboard}
<HeroSection
title="Drivers"
description="Meet the racers who make every lap count. From rookies to champions, track their journey and see who's dominating the grid."
icon={Users}
stats={[
{ label: 'drivers', value: drivers.length, color: 'text-primary-blue' },
{ label: 'active', value: activeCount, color: 'text-performance-green', animate: true },
{ label: 'total wins', value: totalWins.toLocaleString(), color: 'text-warning-amber' },
{ label: 'races', value: totalRaces.toLocaleString(), color: 'text-neon-aqua' },
]}
actions={[
{
label: 'View Leaderboard',
onClick: onViewLeaderboard,
icon: Trophy,
description: 'See full driver rankings'
}
]}
/>
{/* Search */}

View File

@@ -26,6 +26,7 @@ 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';
import { ModeGuard } from '@/components/shared/ModeGuard';
export interface HomeViewData {
isAlpha: boolean;
@@ -152,7 +153,7 @@ export function HomeTemplate({ viewData }: HomeTemplateProps) {
/>
{/* Alpha-only discovery section */}
{viewData.isAlpha && (
<ModeGuard feature="alpha_discovery">
<Container size="lg" py={12}>
<Stack gap={8}>
<Box>
@@ -266,7 +267,7 @@ export function HomeTemplate({ viewData }: HomeTemplateProps) {
</Grid>
</Stack>
</Container>
)}
</ModeGuard>
<DiscordCTA />
<FAQ />

View File

@@ -6,7 +6,7 @@ import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
import { LeagueTabs } from '@/components/leagues/LeagueTabs';
import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData';

View File

@@ -13,6 +13,7 @@ import PointsTable from '@/components/leagues/PointsTable';
import { RulebookTabs, type RulebookSection } from '@/components/leagues/RulebookTabs';
import type { LeagueRulebookViewData } from '@/lib/view-data/LeagueRulebookViewData';
import { Surface } from '@/ui/Surface';
import { Clock } from 'lucide-react';
interface LeagueRulebookTemplateProps {
viewData: LeagueRulebookViewData;
@@ -81,6 +82,34 @@ export function LeagueRulebookTemplate({
<StatItem label="Drop Policy" value={scoringConfig.dropPolicySummary.includes('All') ? 'None' : 'Active'} />
</Grid>
{/* Weekend Structure */}
<Card>
<Stack gap={4}>
<Stack direction="row" align="center" gap={2}>
<Clock className="w-5 h-5 text-primary-blue" />
<Heading level={2}>Weekend Structure & Timings</Heading>
</Stack>
<Grid cols={4} gap={4}>
<Surface variant="muted" rounded="lg" border padding={3}>
<Text size="xs" color="text-gray-500" block mb={1} style={{ textTransform: 'uppercase' }}>Practice</Text>
<Text weight="medium" color="text-white">20 min</Text>
</Surface>
<Surface variant="muted" rounded="lg" border padding={3}>
<Text size="xs" color="text-gray-500" block mb={1} style={{ textTransform: 'uppercase' }}>Qualifying</Text>
<Text weight="medium" color="text-white">30 min</Text>
</Surface>
<Surface variant="muted" rounded="lg" border padding={3}>
<Text size="xs" color="text-gray-500" block mb={1} style={{ textTransform: 'uppercase' }}>Sprint</Text>
<Text weight="medium" color="text-white"></Text>
</Surface>
<Surface variant="muted" rounded="lg" border padding={3}>
<Text size="xs" color="text-gray-500" block mb={1} style={{ textTransform: 'uppercase' }}>Main Race</Text>
<Text weight="medium" color="text-white">40 min</Text>
</Surface>
</Grid>
</Stack>
</Card>
{/* Points Table */}
<PointsTable points={positionPoints} />

View File

@@ -1,16 +1,12 @@
'use client';
import React from 'react';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { 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';
import LeagueSchedule from '@/components/leagues/LeagueSchedule';
interface LeagueScheduleTemplateProps {
viewData: LeagueScheduleViewData;
@@ -26,25 +22,7 @@ export function LeagueScheduleTemplate({ viewData }: LeagueScheduleTemplateProps
</Text>
</Box>
{viewData.races.length === 0 ? (
<Card>
<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>
) : (
<Stack gap={4}>
{viewData.races.map((race) => (
<ScheduleRaceCard key={race.id} race={race} />
))}
</Stack>
)}
<LeagueSchedule leagueId={viewData.leagueId} />
</Stack>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import React from 'react';
import React, { useState } from 'react';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
@@ -9,107 +9,145 @@ 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 { Building, Clock, Palette } from 'lucide-react';
import type { LeagueSponsorshipsViewData } from '@/lib/view-data/leagues/LeagueSponsorshipsViewData';
import { SponsorshipSlotCard } from '@/components/leagues/SponsorshipSlotCard';
import { SponsorshipRequestCard } from '@/components/leagues/SponsorshipRequestCard';
import LeagueDecalPlacementEditor from '@/components/leagues/LeagueDecalPlacementEditor';
interface LeagueSponsorshipsTemplateProps {
viewData: LeagueSponsorshipsViewData;
}
export function LeagueSponsorshipsTemplate({ viewData }: LeagueSponsorshipsTemplateProps) {
const [activeTab, setActiveTab] = useState<'overview' | 'editor'>('overview');
return (
<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>
<Stack gap={6}>
{/* Sponsorship Slots */}
<Card>
<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 ? (
<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>
<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 ? (
<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>
<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>
<Stack direction="row" align="center" justify="between">
<Box>
<Heading level={2}>Sponsorships</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
Manage sponsorship slots and review requests
</Text>
</Box>
<Stack direction="row" gap={2}>
<button
onClick={() => setActiveTab('overview')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === 'overview'
? 'bg-primary-blue text-white'
: 'bg-iron-gray text-gray-400 hover:text-white'
}`}
>
Overview
</button>
<button
onClick={() => setActiveTab('editor')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === 'editor'
? 'bg-primary-blue text-white'
: 'bg-iron-gray text-gray-400 hover:text-white'
}`}
>
Livery Editor
</button>
</Stack>
</Stack>
{activeTab === 'overview' ? (
<Stack gap={6}>
{/* Sponsorship Slots */}
<Card>
<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 ? (
<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>
<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 ? (
<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>
</Stack>
) : (
<Card>
<Stack gap={6}>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(168, 85, 247, 0.1)' }}>
<Icon icon={Palette} size={5} color="#a855f7" />
</Surface>
<Box>
<Heading level={3}>League Livery Editor</Heading>
<Text size="sm" color="text-gray-400">Configure where sponsor decals appear on league cars</Text>
</Box>
</Stack>
<LeagueDecalPlacementEditor
leagueId={viewData.leagueId}
seasonId="current"
carId="gt3-r"
carName="Porsche 911 GT3 R (992)"
onSave={(placements) => {
console.log('Placements saved:', placements);
}}
/>
</Stack>
</Card>
)}
</Stack>
);
}

View File

@@ -8,9 +8,10 @@ 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 { Wallet, Calendar, DollarSign } from 'lucide-react';
import type { LeagueWalletViewData } from '@/lib/view-data/leagues/LeagueWalletViewData';
import { TransactionRow } from '@/components/leagues/TransactionRow';
import { LeagueMembershipFeesSection } from '@/components/leagues/LeagueMembershipFeesSection';
interface LeagueWalletTemplateProps {
viewData: LeagueWalletViewData;
@@ -70,6 +71,22 @@ export function LeagueWalletTemplate({ viewData }: LeagueWalletTemplateProps) {
</Stack>
</Card>
{/* Membership Fees */}
<Card>
<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={DollarSign} size={5} color="#10b981" />
</Surface>
<Box>
<Heading level={3}>Membership Fees</Heading>
<Text size="sm" color="text-gray-400">Configure how drivers pay for participation</Text>
</Box>
</Stack>
<LeagueMembershipFeesSection leagueId={viewData.leagueId} />
</Stack>
</Card>
{/* Note about features */}
<Card>
<Stack align="center" py={8} gap={4}>

View File

@@ -25,7 +25,7 @@ import {
History,
User,
} from 'lucide-react';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
export type ProfileTab = 'overview' | 'history' | 'stats';

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
import RaceStewardingStats from '@/components/races/RaceStewardingStats';
import { StewardingTabs } from '@/components/races/StewardingTabs';
import { ProtestCard } from '@/components/races/ProtestCard';

View File

@@ -4,7 +4,7 @@ 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 { Breadcrumbs } from '@/components/layout/Breadcrumbs';
import {
Flag,
SlidersHorizontal,

View File

@@ -1,6 +1,6 @@
'use client';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
import { SlotTemplates } from '@/components/sponsors/SlotTemplates';
import SponsorInsightsCard from '@/components/sponsors/SponsorInsightsCard';
import { useSponsorMode } from '@/hooks/sponsor/useSponsorMode';

View File

@@ -2,33 +2,74 @@ import React, { forwardRef, ForwardedRef, ElementType, ComponentPropsWithoutRef
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> {
interface ResponsiveSpacing {
base?: Spacing;
md?: Spacing;
lg?: Spacing;
}
export 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;
m?: Spacing | ResponsiveSpacing;
mt?: Spacing | ResponsiveSpacing;
mb?: Spacing | ResponsiveSpacing;
ml?: Spacing | ResponsiveSpacing;
mr?: Spacing | ResponsiveSpacing;
mx?: Spacing | 'auto' | ResponsiveSpacing;
my?: Spacing | ResponsiveSpacing;
p?: Spacing | ResponsiveSpacing;
pt?: Spacing | ResponsiveSpacing;
pb?: Spacing | ResponsiveSpacing;
pl?: Spacing | ResponsiveSpacing;
pr?: Spacing | ResponsiveSpacing;
px?: Spacing | ResponsiveSpacing;
py?: Spacing | ResponsiveSpacing;
display?: 'block' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'none';
flexDirection?: 'row' | 'row-reverse' | 'col' | 'col-reverse';
alignItems?: 'start' | 'center' | 'end' | 'stretch' | 'baseline';
justifyContent?: 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly';
flexWrap?: 'wrap' | 'nowrap' | 'wrap-reverse';
flexShrink?: number;
flexGrow?: number;
gridCols?: number | string;
responsiveGridCols?: {
base?: number | string;
md?: number | string;
lg?: number | string;
};
gap?: Spacing;
position?: 'relative' | 'absolute' | 'fixed' | 'sticky';
top?: Spacing | string;
bottom?: Spacing | string;
left?: Spacing | string;
right?: Spacing | string;
overflow?: 'visible' | 'hidden' | 'scroll' | 'auto';
maxWidth?: string;
zIndex?: number;
w?: string | ResponsiveValue<string>;
h?: string | ResponsiveValue<string>;
width?: string;
height?: string;
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
border?: boolean;
borderColor?: string;
bg?: string;
shadow?: string;
hoverBorderColor?: string;
transition?: boolean;
}
type ResponsiveValue<T> = {
base?: T;
md?: T;
lg?: T;
};
export const Box = forwardRef(<T extends ElementType = 'div'>(
{
as,
@@ -41,8 +82,27 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
p, pt, pb, pl, pr, px, py,
display,
position,
top,
bottom,
left,
right,
overflow,
maxWidth,
zIndex,
gridCols,
responsiveGridCols,
gap,
w,
h,
rounded,
border,
borderColor,
bg,
shadow,
flexShrink,
flexGrow,
hoverBorderColor,
transition,
...props
}: BoxProps<T> & ComponentPropsWithoutRef<T>,
ref: ForwardedRef<HTMLElement>
@@ -57,31 +117,83 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
'auto': 'auto'
};
const getSpacingClass = (prefix: string, value: Spacing | 'auto' | ResponsiveSpacing | undefined) => {
if (value === undefined) return '';
if (typeof value === 'object') {
const classes = [];
if (value.base !== undefined) classes.push(`${prefix}-${spacingMap[value.base]}`);
if (value.md !== undefined) classes.push(`md:${prefix}-${spacingMap[value.md]}`);
if (value.lg !== undefined) classes.push(`lg:${prefix}-${spacingMap[value.lg]}`);
return classes.join(' ');
}
return `${prefix}-${spacingMap[value]}`;
};
const getResponsiveClasses = (prefix: string, value: string | ResponsiveValue<string> | undefined) => {
if (value === undefined) return '';
if (typeof value === 'object') {
const classes = [];
if (value.base !== undefined) classes.push(`${prefix}-${value.base}`);
if (value.md !== undefined) classes.push(`md:${prefix}-${value.md}`);
if (value.lg !== undefined) classes.push(`lg:${prefix}-${value.lg}`);
return classes.join(' ');
}
return `${prefix}-${value}`;
};
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]}` : '',
getSpacingClass('m', m),
getSpacingClass('mt', mt),
getSpacingClass('mb', mb),
getSpacingClass('ml', ml),
getSpacingClass('mr', mr),
getSpacingClass('mx', mx),
getSpacingClass('my', my),
getSpacingClass('p', p),
getSpacingClass('pt', pt),
getSpacingClass('pb', pb),
getSpacingClass('pl', pl),
getSpacingClass('pr', pr),
getSpacingClass('px', px),
getSpacingClass('py', py),
getResponsiveClasses('w', w),
getResponsiveClasses('h', h),
rounded ? `rounded-${rounded}` : '',
border ? 'border' : '',
borderColor ? borderColor : '',
bg ? bg : '',
shadow ? shadow : '',
flexShrink !== undefined ? `flex-shrink-${flexShrink}` : '',
flexGrow !== undefined ? `flex-grow-${flexGrow}` : '',
hoverBorderColor ? `hover:${hoverBorderColor}` : '',
transition ? 'transition-all' : '',
display ? display : '',
gridCols ? `grid-cols-${gridCols}` : '',
responsiveGridCols?.base ? `grid-cols-${responsiveGridCols.base}` : '',
responsiveGridCols?.md ? `md:grid-cols-${responsiveGridCols.md}` : '',
responsiveGridCols?.lg ? `lg:grid-cols-${responsiveGridCols.lg}` : '',
gap !== undefined ? `gap-${spacingMap[gap]}` : '',
position ? position : '',
top !== undefined && spacingMap[top as any] ? `top-${spacingMap[top as any]}` : '',
bottom !== undefined && spacingMap[bottom as any] ? `bottom-${spacingMap[bottom as any]}` : '',
left !== undefined && spacingMap[left as any] ? `left-${spacingMap[left as any]}` : '',
right !== undefined && spacingMap[right as any] ? `right-${spacingMap[right as any]}` : '',
overflow ? `overflow-${overflow}` : '',
zIndex !== undefined ? `z-${zIndex}` : '',
className
].filter(Boolean).join(' ');
const style = maxWidth ? { maxWidth, ...((props as Record<string, unknown>).style as object || {}) } : (props as Record<string, unknown>).style;
const style: React.CSSProperties = {
...(maxWidth ? { maxWidth } : {}),
...(top !== undefined && !spacingMap[top as any] ? { top } : {}),
...(bottom !== undefined && !spacingMap[bottom as any] ? { bottom } : {}),
...(left !== undefined && !spacingMap[left as any] ? { left } : {}),
...(right !== undefined && !spacingMap[right as any] ? { right } : {}),
...((props as Record<string, unknown>).style as object || {})
};
return (
<Tag ref={ref as React.ForwardedRef<HTMLElement>} className={classes} {...props} style={style as React.CSSProperties}>

View File

@@ -5,7 +5,7 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
onClick?: MouseEventHandler<HTMLButtonElement>;
className?: string;
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'race-performance' | 'race-final';
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'race-performance' | 'race-final' | 'discord';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
@@ -13,9 +13,11 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
fullWidth?: boolean;
as?: 'button' | 'a';
href?: string;
target?: string;
rel?: string;
}
export function Button({
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({
children,
onClick,
className = '',
@@ -27,8 +29,10 @@ export function Button({
fullWidth = false,
as = 'button',
href,
target,
rel,
...props
}: ButtonProps) {
}, ref) => {
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 = {
@@ -37,7 +41,8 @@ export function Button({
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',
'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'
'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',
discord: 'bg-[#5865F2] text-white hover:bg-[#4752C4] shadow-[0_0_20px_rgba(88,101,242,0.3)] hover:shadow-[0_0_30px_rgba(88,101,242,0.6)] focus-visible:outline-[#5865F2]'
};
const sizeClasses = {
@@ -69,6 +74,8 @@ export function Button({
return (
<a
href={href}
target={target}
rel={rel}
className={classes}
{...(props as React.AnchorHTMLAttributes<HTMLAnchorElement>)}
>
@@ -79,6 +86,7 @@ export function Button({
return (
<button
ref={ref}
type={type}
className={classes}
onClick={onClick}
@@ -88,4 +96,6 @@ export function Button({
{content}
</button>
);
}
});
Button.displayName = 'Button';

View File

@@ -1,19 +1,20 @@
import React, { ReactNode, MouseEventHandler, HTMLAttributes } from 'react';
import { Box, BoxProps } 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 CardProps extends HTMLAttributes<HTMLDivElement> {
interface CardProps extends Omit<BoxProps<'div'>, 'children' | 'className'> {
children: ReactNode;
className?: string;
onClick?: MouseEventHandler<HTMLDivElement>;
variant?: 'default' | 'highlight';
p?: Spacing;
px?: Spacing;
py?: Spacing;
pt?: Spacing;
pb?: Spacing;
pl?: Spacing;
pr?: Spacing;
p?: Spacing | any;
px?: Spacing | any;
py?: Spacing | any;
pt?: Spacing | any;
pb?: Spacing | any;
pl?: Spacing | any;
pr?: Spacing | any;
}
export function Card({
@@ -53,8 +54,8 @@ export function Card({
].filter(Boolean).join(' ');
return (
<div className={classes} onClick={onClick} {...props}>
<Box className={classes} onClick={onClick as any} {...props}>
{children}
</div>
</Box>
);
}

View File

@@ -1,4 +1,6 @@
import React, { ReactNode } from 'react';
import { ReactNode } from 'react';
// TODO very useless component
interface DashboardLayoutWrapperProps {
children: ReactNode;

View File

@@ -5,16 +5,31 @@ import { Box } from './Box';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
variant?: 'default' | 'error';
errorMessage?: string;
icon?: React.ReactNode;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className = '', variant = 'default', errorMessage, ...props }, ref) => {
({ className = '', variant = 'default', errorMessage, icon, ...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}`;
const iconClasses = icon ? 'pl-10' : '';
const classes = `${baseClasses} ${variantClasses} ${iconClasses} ${className}`;
return (
<Box fullWidth>
<Box fullWidth position="relative">
{icon && (
<Box
position="absolute"
left="3"
top="50%"
style={{ transform: 'translateY(-50%)' }}
zIndex={10}
display="flex"
center
>
{icon}
</Box>
)}
<input ref={ref} className={classes} {...props} />
{errorMessage && (
<Text size="xs" color="text-error-red" block mt={1}>

View File

@@ -1,9 +1,9 @@
import React, { ReactNode, HTMLAttributes } from 'react';
import { Box } from './Box';
import { Box, BoxProps } 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> {
interface StackProps extends Omit<BoxProps<'div'>, 'children' | 'className' | 'gap'> {
children: ReactNode;
className?: string;
direction?: 'row' | 'col';
@@ -12,19 +12,20 @@ interface StackProps extends HTMLAttributes<HTMLElement> {
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;
m?: Spacing | any;
mt?: Spacing | any;
mb?: Spacing | any;
ml?: Spacing | any;
mr?: Spacing | any;
p?: Spacing | any;
pt?: Spacing | any;
pb?: Spacing | any;
pl?: Spacing | any;
pr?: Spacing | any;
px?: Spacing | any;
py?: Spacing | any;
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
style?: React.CSSProperties;
}
export function Stack({

View File

@@ -1,17 +1,20 @@
import React, { ReactNode, HTMLAttributes } from 'react';
import { Box } from './Box';
import React, { ReactNode, ElementType, ComponentPropsWithoutRef } from 'react';
import { Box, BoxProps } from './Box';
interface SurfaceProps extends HTMLAttributes<HTMLElement> {
interface SurfaceProps<T extends ElementType = 'div'> extends Omit<BoxProps<T>, 'children' | 'className' | 'display'> {
as?: T;
children: ReactNode;
variant?: 'default' | 'muted' | 'dark' | 'glass' | 'gradient-blue' | 'gradient-gold' | 'gradient-purple';
variant?: 'default' | 'muted' | 'dark' | 'glass' | 'gradient-blue' | 'gradient-gold' | 'gradient-purple' | 'gradient-green' | 'discord' | 'discord-inner';
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
border?: boolean;
padding?: number;
className?: string;
display?: 'block' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'none';
shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'discord' | string;
}
export function Surface({
export function Surface<T extends ElementType = 'div'>({
as,
children,
variant = 'default',
rounded = 'lg',
@@ -19,19 +22,32 @@ export function Surface({
padding = 0,
className = '',
display,
shadow = 'none',
...props
}: SurfaceProps) {
const variantClasses = {
}: SurfaceProps<T> & ComponentPropsWithoutRef<T>) {
const variantClasses: Record<string, string> = {
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'
'gradient-purple': 'bg-gradient-to-br from-purple-600/20 via-iron-gray/80 to-deep-graphite',
'gradient-green': 'bg-gradient-to-br from-green-600/20 via-iron-gray/80 to-deep-graphite',
'discord': 'bg-gradient-to-b from-deep-graphite to-iron-gray',
'discord-inner': 'bg-gradient-to-br from-iron-gray via-deep-graphite to-iron-gray'
};
const roundedClasses = {
const shadowClasses: Record<string, string> = {
none: '',
sm: 'shadow-sm',
md: 'shadow-md',
lg: 'shadow-lg',
xl: 'shadow-xl',
discord: 'shadow-[0_0_80px_rgba(88,101,242,0.15)]'
};
const roundedClasses: Record<string, string> = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
@@ -58,6 +74,7 @@ export function Surface({
roundedClasses[rounded],
border ? 'border border-charcoal-outline' : '',
paddingClasses[padding] || 'p-0',
shadowClasses[shadow],
display ? display : '',
className
].filter(Boolean).join(' ');

Some files were not shown because too many files have changed in this diff Show More