wip
This commit is contained in:
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Card from '../ui/Card';
|
||||
import { Card } from '@/ui/Card';
|
||||
|
||||
interface RatingBreakdownProps {
|
||||
skillRating?: number;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
21
apps/website/lib/display-objects/AchievementDisplay.ts
Normal file
21
apps/website/lib/display-objects/AchievementDisplay.ts
Normal 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()}`;
|
||||
}
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
// TODO very useless component
|
||||
|
||||
interface DashboardLayoutWrapperProps {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user