This commit is contained in:
2026-01-05 19:35:49 +01:00
parent b4b915416b
commit d9e6151ae0
92 changed files with 10964 additions and 7893 deletions

View File

@@ -19,7 +19,13 @@ interface RaceCardProps {
}
export function RaceCard({ race, onClick, className }: RaceCardProps) {
const config = raceStatusConfig[race.status as keyof typeof raceStatusConfig];
const config = raceStatusConfig[race.status as keyof typeof raceStatusConfig] || {
border: 'border-charcoal-outline',
bg: 'bg-charcoal-outline',
color: 'text-gray-400',
icon: () => null,
label: 'Scheduled',
};
return (
<div
@@ -70,7 +76,7 @@ export function RaceCard({ race, onClick, className }: RaceCardProps) {
{/* Status Badge */}
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full ${config.bg} ${config.border} border`}>
<config.icon className={`w-3.5 h-3.5 ${config.color}`} />
{config.icon && <config.icon className={`w-3.5 h-3.5 ${config.color}`} />}
<span className={`text-xs font-medium ${config.color}`}>
{config.label}
</span>

View File

@@ -0,0 +1,151 @@
'use client';
import { X, Filter, Search } from 'lucide-react';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
export type TimeFilter = 'all' | 'upcoming' | 'live' | 'past';
export type StatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
interface RaceFilterModalProps {
isOpen: boolean;
onClose: () => void;
statusFilter: StatusFilter;
setStatusFilter: (filter: StatusFilter) => void;
leagueFilter: string;
setLeagueFilter: (filter: string) => void;
timeFilter: TimeFilter;
setTimeFilter: (filter: TimeFilter) => void;
searchQuery: string;
setSearchQuery: (query: string) => void;
leagues: Array<{ id: string; name: string }>;
showSearch?: boolean;
showTimeFilter?: boolean;
}
export function RaceFilterModal({
isOpen,
onClose,
statusFilter,
setStatusFilter,
leagueFilter,
setLeagueFilter,
timeFilter,
setTimeFilter,
searchQuery,
setSearchQuery,
leagues,
showSearch = true,
showTimeFilter = true,
}: RaceFilterModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
<div className="w-full max-w-md" onClick={(e) => e.stopPropagation()}>
<Card className="!p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-primary-blue" />
<h3 className="text-lg font-semibold text-white">Filters</h3>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-white">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
{/* Search */}
{showSearch && (
<div>
<label className="block text-sm text-gray-400 mb-2">Search</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Track, car, or league..."
className="w-full pl-10 pr-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-blue"
/>
</div>
</div>
)}
{/* Time Filter */}
{showTimeFilter && (
<div>
<label className="block text-sm text-gray-400 mb-2">Time</label>
<div className="flex gap-2">
{(['upcoming', 'live', 'past', 'all'] as TimeFilter[]).map(filter => (
<button
key={filter}
onClick={() => setTimeFilter(filter)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
timeFilter === filter
? 'bg-primary-blue text-white'
: 'bg-deep-graphite text-gray-400 hover:text-white'
}`}
>
{filter === 'live' && <span className="inline-block w-2 h-2 bg-performance-green rounded-full mr-1 animate-pulse" />}
{filter.charAt(0).toUpperCase() + filter.slice(1)}
</button>
))}
</div>
</div>
)}
{/* Status Filter */}
<div>
<label className="block text-sm text-gray-400 mb-2">Status</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
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="all">All Statuses</option>
<option value="scheduled">Scheduled</option>
<option value="running">Live</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
{/* League Filter */}
<div>
<label className="block text-sm text-gray-400 mb-2">League</label>
<select
value={leagueFilter}
onChange={(e) => setLeagueFilter(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="all">All Leagues</option>
{leagues.map(league => (
<option key={league.id} value={league.id}>
{league.name}
</option>
))}
</select>
</div>
{/* Clear Filters */}
{(statusFilter !== 'all' || leagueFilter !== 'all' || searchQuery || (showTimeFilter && timeFilter !== 'upcoming')) && (
<Button
variant="secondary"
onClick={() => {
setStatusFilter('all');
setLeagueFilter('all');
setSearchQuery('');
if (showTimeFilter) setTimeFilter('upcoming');
}}
className="w-full"
>
Clear All Filters
</Button>
)}
</div>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
import { UserPlus, UserMinus, CheckCircle2, PlayCircle, XCircle } from 'lucide-react';
import Button from '@/components/ui/Button';
interface RaceJoinButtonProps {
raceStatus: 'scheduled' | 'running' | 'completed' | 'cancelled';
isUserRegistered: boolean;
canRegister: boolean;
onRegister: () => void;
onWithdraw: () => void;
onCancel: () => void;
onReopen?: () => void;
onEndRace?: () => void;
canReopenRace?: boolean;
isOwnerOrAdmin?: boolean;
isLoading?: {
register?: boolean;
withdraw?: boolean;
cancel?: boolean;
reopen?: boolean;
};
}
export function RaceJoinButton({
raceStatus,
isUserRegistered,
canRegister,
onRegister,
onWithdraw,
onCancel,
onReopen,
onEndRace,
canReopenRace = false,
isOwnerOrAdmin = false,
isLoading = {},
}: RaceJoinButtonProps) {
// Show registration button for scheduled races
if (raceStatus === 'scheduled') {
if (canRegister && !isUserRegistered) {
return (
<Button
variant="primary"
className="w-full flex items-center justify-center gap-2"
onClick={onRegister}
disabled={isLoading.register}
>
<UserPlus className="w-4 h-4" />
{isLoading.register ? 'Registering...' : 'Register for Race'}
</Button>
);
}
if (isUserRegistered) {
return (
<>
<div className="flex items-center gap-2 px-4 py-3 bg-performance-green/10 border border-performance-green/30 rounded-lg text-performance-green">
<CheckCircle2 className="w-5 h-5" />
<span className="font-medium">You're Registered</span>
</div>
<Button
variant="secondary"
className="w-full flex items-center justify-center gap-2"
onClick={onWithdraw}
disabled={isLoading.withdraw}
>
<UserMinus className="w-4 h-4" />
{isLoading.withdraw ? 'Withdrawing...' : 'Withdraw'}
</Button>
</>
);
}
// Show cancel button for owners/admins
if (isOwnerOrAdmin) {
return (
<Button
variant="secondary"
className="w-full flex items-center justify-center gap-2"
onClick={onCancel}
disabled={isLoading.cancel}
>
<XCircle className="w-4 h-4" />
{isLoading.cancel ? 'Cancelling...' : 'Cancel Race'}
</Button>
);
}
return null;
}
// Show end race button for running races (owners/admins only)
if (raceStatus === 'running' && isOwnerOrAdmin && onEndRace) {
return (
<Button
variant="primary"
className="w-full flex items-center justify-center gap-2"
onClick={onEndRace}
>
<CheckCircle2 className="w-4 h-4" />
End Race & Process Results
</Button>
);
}
// Show reopen button for completed/cancelled races (owners/admins only)
if ((raceStatus === 'completed' || raceStatus === 'cancelled') && canReopenRace && isOwnerOrAdmin && onReopen) {
return (
<Button
variant="secondary"
className="w-full flex items-center justify-center gap-2"
onClick={onReopen}
disabled={isLoading.reopen}
>
<PlayCircle className="w-4 h-4" />
{isLoading.reopen ? 'Re-opening...' : 'Re-open Race'}
</Button>
);
}
return null;
}

View File

@@ -0,0 +1,84 @@
'use client';
import { ChevronLeft, ChevronRight } from 'lucide-react';
interface RacePaginationProps {
currentPage: number;
totalPages: number;
totalItems: number;
itemsPerPage: number;
onPageChange: (page: number) => void;
}
export function RacePagination({
currentPage,
totalPages,
totalItems,
itemsPerPage,
onPageChange,
}: RacePaginationProps) {
if (totalPages <= 1) return null;
const startItem = ((currentPage - 1) * itemsPerPage) + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
const getPageNumbers = () => {
const pages: number[] = [];
if (totalPages <= 5) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
if (currentPage <= 3) {
return [1, 2, 3, 4, 5];
}
if (currentPage >= totalPages - 2) {
return [totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
}
return [currentPage - 2, currentPage - 1, currentPage, currentPage + 1, currentPage + 2];
};
return (
<div className="flex items-center justify-between pt-4">
<p className="text-sm text-gray-500">
Showing {startItem}{endItem} of {totalItems}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="p-2 rounded-lg border border-charcoal-outline text-gray-400 hover:text-white hover:border-primary-blue disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-5 h-5" />
</button>
<div className="flex items-center gap-1">
{getPageNumbers().map(pageNum => (
<button
key={pageNum}
onClick={() => onPageChange(pageNum)}
className={`w-10 h-10 rounded-lg text-sm font-medium transition-colors ${
currentPage === pageNum
? 'bg-primary-blue text-white'
: 'text-gray-400 hover:text-white hover:bg-iron-gray'
}`}
>
{pageNum}
</button>
))}
</div>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="p-2 rounded-lg border border-charcoal-outline text-gray-400 hover:text-white hover:border-primary-blue disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import { useState } from 'react';
export type StewardingTab = 'pending' | 'resolved' | 'penalties';
interface StewardingTabsProps {
activeTab: StewardingTab;
onTabChange: (tab: StewardingTab) => void;
pendingCount: number;
}
export function StewardingTabs({ activeTab, onTabChange, pendingCount }: StewardingTabsProps) {
const tabs: Array<{ id: StewardingTab; label: string }> = [
{ id: 'pending', label: 'Pending' },
{ id: 'resolved', label: 'Resolved' },
{ id: 'penalties', label: 'Penalties' },
];
return (
<div className="border-b border-charcoal-outline">
<div className="flex gap-4">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === tab.id
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
{tab.label}
{tab.id === 'pending' && pendingCount > 0 && (
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
{pendingCount}
</span>
)}
</button>
))}
</div>
</div>
);
}