extract components from website

This commit is contained in:
2025-12-21 13:55:31 +01:00
parent 13d8563feb
commit b52474d792
65 changed files with 3234 additions and 1361 deletions

View File

@@ -27,6 +27,7 @@ import Input from '@/components/ui/Input';
import Heading from '@/components/ui/Heading';
import { useAuth } from '@/lib/auth/AuthContext';
import AuthWorkflowMockup from '@/components/auth/AuthWorkflowMockup';
import UserRolesPreview from '@/components/auth/UserRolesPreview';
interface FormErrors {
email?: string;
@@ -34,26 +35,6 @@ interface FormErrors {
submit?: string;
}
const USER_ROLES = [
{
icon: Car,
title: 'Driver',
description: 'Race, track stats, join teams',
color: 'primary-blue',
},
{
icon: Trophy,
title: 'League Admin',
description: 'Organize leagues and events',
color: 'performance-green',
},
{
icon: Users,
title: 'Team Manager',
description: 'Manage team and drivers',
color: 'purple-400',
},
];
export default function LoginPage() {
const router = useRouter();
@@ -167,25 +148,7 @@ export default function LoginPage() {
</p>
{/* Role Cards */}
<div className="space-y-3 mb-8">
{USER_ROLES.map((role, index) => (
<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"
>
<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>
))}
</div>
<UserRolesPreview variant="full" />
{/* Workflow Mockup */}
<AuthWorkflowMockup />
@@ -365,19 +328,7 @@ export default function LoginPage() {
</p>
{/* Mobile Role Info */}
<div className="mt-8 lg:hidden">
<p className="text-center text-xs text-gray-500 mb-4">One account for all roles</p>
<div className="flex justify-center gap-6">
{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>
))}
</div>
</div>
<UserRolesPreview variant="compact" />
</div>
</div>
</main>

View File

@@ -23,58 +23,15 @@ import {
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { StatCard } from '@/components/dashboard/StatCard';
import { LeagueStandingItem } from '@/components/dashboard/LeagueStandingItem';
import { UpcomingRaceItem } from '@/components/dashboard/UpcomingRaceItem';
import { FriendItem } from '@/components/dashboard/FriendItem';
import { FeedItemRow } from '@/components/dashboard/FeedItemRow';
import { useDashboardOverview } from '@/hooks/useDashboardService';
// Helper functions
function getCountryFlag(countryCode: string): string {
const code = countryCode.toUpperCase();
if (code.length === 2) {
const codePoints = [...code].map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
return '🏁';
}
function timeUntil(date: Date): string {
const now = new Date();
const diffMs = date.getTime() - now.getTime();
if (diffMs < 0) return 'Started';
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) {
return `${diffDays}d ${diffHours % 24}h`;
}
if (diffHours > 0) {
const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
return `${diffHours}h ${diffMinutes}m`;
}
const diffMinutes = Math.floor(diffMs / (1000 * 60));
return `${diffMinutes}m`;
}
function timeAgo(timestamp: Date | string): string {
const time = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
const diffMs = Date.now() - time.getTime();
const diffMinutes = Math.floor(diffMs / 60000);
if (diffMinutes < 1) return 'Just now';
if (diffMinutes < 60) return `${diffMinutes}m ago`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
function getGreeting(): string {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning';
if (hour < 18) return 'Good afternoon';
return 'Good evening';
}
import { getCountryFlag } from '@/lib/utilities/country';
import { getGreeting, timeUntil, timeAgo } from '@/lib/utilities/time';
import { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
@@ -176,50 +133,10 @@ export default function DashboardPage() {
{/* Quick Stats Row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
<div className="p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/20">
<Trophy className="w-5 h-5 text-performance-green" />
</div>
<div>
<p className="text-2xl font-bold text-white">{wins}</p>
<p className="text-xs text-gray-500">Wins</p>
</div>
</div>
</div>
<div className="p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/20">
<Medal className="w-5 h-5 text-warning-amber" />
</div>
<div>
<p className="text-2xl font-bold text-white">{podiums}</p>
<p className="text-xs text-gray-500">Podiums</p>
</div>
</div>
</div>
<div className="p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/20">
<Target className="w-5 h-5 text-primary-blue" />
</div>
<div>
<p className="text-2xl font-bold text-white">{consistency}%</p>
<p className="text-xs text-gray-500">Consistency</p>
</div>
</div>
</div>
<div className="p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/20">
<Users className="w-5 h-5 text-purple-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{activeLeaguesCount}</p>
<p className="text-xs text-gray-500">Active Leagues</p>
</div>
</div>
</div>
<StatCard icon={Trophy} value={wins} label="Wins" color="bg-performance-green/20 text-performance-green" />
<StatCard icon={Medal} value={podiums} label="Podiums" color="bg-warning-amber/20 text-warning-amber" />
<StatCard icon={Target} value={`${consistency}%`} label="Consistency" color="bg-primary-blue/20 text-primary-blue" />
<StatCard icon={Users} value={activeLeaguesCount} label="Active Leagues" color="bg-purple-500/20 text-purple-400" />
</div>
</div>
</section>
@@ -300,38 +217,14 @@ export default function DashboardPage() {
</div>
<div className="space-y-3">
{leagueStandingsSummaries.map(({ leagueId, leagueName, position, points, totalDrivers }) => (
<Link
<LeagueStandingItem
key={leagueId}
href={`/leagues/${leagueId}/standings`}
className="flex items-center gap-4 p-4 rounded-xl bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/30 transition-colors group"
>
<div className={`flex h-12 w-12 items-center justify-center rounded-xl font-bold text-xl ${
position === 1 ? 'bg-yellow-400/20 text-yellow-400' :
position === 2 ? 'bg-gray-300/20 text-gray-300' :
position === 3 ? 'bg-orange-400/20 text-orange-400' :
'bg-iron-gray text-gray-400'
}`}>
{position > 0 ? `P${position}` : '-'}
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-semibold truncate group-hover:text-primary-blue transition-colors">
{leagueName}
</p>
<p className="text-sm text-gray-500">
{points} points {totalDrivers} drivers
</p>
</div>
<div className="flex items-center gap-2">
{position <= 3 && position > 0 && (
<Crown className={`w-5 h-5 ${
position === 1 ? 'text-yellow-400' :
position === 2 ? 'text-gray-300' :
'text-orange-400'
}`} />
)}
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-primary-blue transition-colors" />
</div>
</Link>
leagueId={leagueId}
leagueName={leagueName}
position={position}
points={points}
totalDrivers={totalDrivers}
/>
))}
</div>
</Card>
@@ -376,30 +269,16 @@ export default function DashboardPage() {
</div>
{upcomingRaces.length > 0 ? (
<div className="space-y-3">
{upcomingRaces.slice(0, 5).map((race) => {
const isMyRace = race.isMyLeague;
return (
<Link
key={race.id}
href={`/races/${race.id}`}
className="block p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/30 transition-colors"
>
<div className="flex items-start justify-between gap-2 mb-2">
<p className="text-white font-medium text-sm truncate">{race.track}</p>
{isMyRace && (
<span className="flex-shrink-0 w-2 h-2 rounded-full bg-performance-green" title="Your league" />
)}
</div>
<p className="text-xs text-gray-500 truncate mb-2">{race.car}</p>
<div className="flex items-center justify-between text-xs">
<span className="text-gray-400">
{race.scheduledAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
<span className="text-primary-blue font-medium">{timeUntil(race.scheduledAt)}</span>
</div>
</Link>
);
})}
{upcomingRaces.slice(0, 5).map((race) => (
<UpcomingRaceItem
key={race.id}
id={race.id}
track={race.track}
car={race.car}
scheduledAt={race.scheduledAt}
isMyLeague={race.isMyLeague}
/>
))}
</div>
) : (
<p className="text-gray-500 text-sm text-center py-4">No upcoming races</p>
@@ -418,25 +297,13 @@ export default function DashboardPage() {
{friends.length > 0 ? (
<div className="space-y-2">
{friends.slice(0, 6).map((friend) => (
<Link
<FriendItem
key={friend.id}
href={`/drivers/${friend.id}`}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite transition-colors"
>
<div className="w-9 h-9 rounded-full overflow-hidden bg-gradient-to-br from-primary-blue to-purple-600">
<Image
src={friend.avatarUrl}
alt={friend.name}
width={36}
height={36}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate">{friend.name}</p>
<p className="text-xs text-gray-500">{getCountryFlag(friend.country)}</p>
</div>
</Link>
id={friend.id}
name={friend.name}
avatarUrl={friend.avatarUrl}
country={friend.country}
/>
))}
{friends.length > 6 && (
<Link
@@ -465,40 +332,3 @@ export default function DashboardPage() {
</main>
);
}
// Feed Item Row Component
function FeedItemRow({ item }: { item: DashboardFeedItemSummaryViewModel }) {
const getActivityIcon = (type: string) => {
if (type.includes('win')) return { icon: Trophy, color: 'text-yellow-400 bg-yellow-400/10' };
if (type.includes('podium')) return { icon: Medal, color: 'text-warning-amber bg-warning-amber/10' };
if (type.includes('join')) return { icon: UserPlus, color: 'text-performance-green bg-performance-green/10' };
if (type.includes('friend')) return { icon: Heart, color: 'text-pink-400 bg-pink-400/10' };
if (type.includes('league')) return { icon: Flag, color: 'text-primary-blue bg-primary-blue/10' };
if (type.includes('race')) return { icon: Play, color: 'text-red-400 bg-red-400/10' };
return { icon: Activity, color: 'text-gray-400 bg-gray-400/10' };
};
const { icon: Icon, color } = getActivityIcon(item.type);
return (
<div className="flex gap-3 p-3 rounded-lg bg-deep-graphite/50 border border-charcoal-outline">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${color} flex-shrink-0`}>
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-white">{item.headline}</p>
{item.body && (
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{item.body}</p>
)}
<p className="text-xs text-gray-500 mt-1">{timeAgo(item.timestamp)}</p>
</div>
{item.ctaHref && (
<Link href={item.ctaHref} className="flex-shrink-0">
<Button variant="secondary" className="text-xs px-3 py-1.5">
{item.ctaLabel || 'View'}
</Button>
</Link>
)}
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import Card from '@/components/ui/Card';
import PointsTable from '@/components/leagues/PointsTable';
import { useServices } from '@/lib/services/ServiceProvider';
import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
@@ -124,49 +125,7 @@ export default function LeagueRulebookPage() {
</div>
{/* Points Table */}
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Points Distribution</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 font-medium text-gray-400">Position</th>
<th className="text-right py-3 px-4 font-medium text-gray-400">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 ${
position <= 3 ? 'bg-iron-gray/20' : ''
}`}
>
<td className="py-3 px-4">
<div className="flex items-center gap-3">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold ${
position === 1 ? 'bg-yellow-500 text-black' :
position === 2 ? 'bg-gray-400 text-black' :
position === 3 ? 'bg-amber-600 text-white' :
'bg-charcoal-outline text-white'
}`}>
{position}
</div>
<span className="text-white font-medium">
{position === 1 ? '1st' : position === 2 ? '2nd' : position === 3 ? '3rd' : `${position}th`}
</span>
</div>
</td>
<td className="py-3 px-4 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>
<PointsTable points={positionPoints} />
{/* Bonus Points */}
{primaryChampionship?.bonusSummary && primaryChampionship.bonusSummary.length > 0 && (

View File

@@ -1,8 +1,7 @@
'use client';
import { ReadonlyLeagueInfo } from '@/components/leagues/ReadonlyLeagueInfo';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
import Button from '@/components/ui/Button';
import LeagueOwnershipTransfer from '@/components/leagues/LeagueOwnershipTransfer';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
@@ -22,9 +21,6 @@ export default function LeagueSettingsPage() {
const [settings, setSettings] = useState<LeagueSettingsViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [showTransferDialog, setShowTransferDialog] = useState(false);
const [selectedNewOwner, setSelectedNewOwner] = useState<string>('');
const [transferring, setTransferring] = useState(false);
const router = useRouter();
useEffect(() => {
@@ -58,20 +54,12 @@ export default function LeagueSettingsPage() {
const ownerSummary = settings?.owner || null;
const handleTransferOwnership = async () => {
if (!selectedNewOwner || !settings) return;
setTransferring(true);
const handleTransferOwnership = async (newOwnerId: string) => {
try {
await leagueSettingsService.transferOwnership(leagueId, currentDriverId, selectedNewOwner);
setShowTransferDialog(false);
await leagueSettingsService.transferOwnership(leagueId, currentDriverId, newOwnerId);
router.refresh();
} catch (err) {
console.error('Failed to transfer ownership:', err);
alert(err instanceof Error ? err.message : 'Failed to transfer ownership');
} finally {
setTransferring(false);
throw err; // Let the component handle the error
}
};
@@ -128,76 +116,11 @@ export default function LeagueSettingsPage() {
<div className="space-y-4">
<ReadonlyLeagueInfo league={settings.league} configForm={settings.config} />
{/* League Owner - Compact */}
<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>
{ownerSummary ? (
<DriverSummaryPill
driver={ownerSummary.driver}
rating={ownerSummary.rating}
rank={ownerSummary.rank}
/>
) : (
<p className="text-sm text-gray-500">Loading owner details...</p>
)}
</div>
{/* 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>
{!showTransferDialog ? (
<Button
variant="secondary"
onClick={() => setShowTransferDialog(true)}
>
Transfer Ownership
</Button>
) : (
<div className="space-y-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>
<div className="flex gap-2">
<Button
variant="primary"
onClick={handleTransferOwnership}
disabled={!selectedNewOwner || transferring}
>
{transferring ? 'Transferring...' : 'Confirm Transfer'}
</Button>
<Button
variant="secondary"
onClick={() => {
setShowTransferDialog(false);
setSelectedNewOwner('');
}}
disabled={transferring}
>
Cancel
</Button>
</div>
</div>
)}
</div>
)}
<LeagueOwnershipTransfer
settings={settings}
currentDriverId={currentDriverId}
onTransferOwnership={handleTransferOwnership}
/>
</div>
</div>
);

View File

@@ -1,6 +1,7 @@
'use client';
import StandingsTable from '@/components/leagues/StandingsTable';
import LeagueChampionshipStats from '@/components/leagues/LeagueChampionshipStats';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import type { LeagueMembership, MembershipRole } from '@/lib/types';
@@ -86,54 +87,10 @@ export default function LeagueStandingsPage() {
);
}
const leader = standings[0];
const totalRaces = Math.max(...standings.map(s => s.races), 0);
return (
<div className="space-y-6">
{/* Championship Stats */}
{standings.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-yellow-500/10 flex items-center justify-center">
<span className="text-2xl">🏆</span>
</div>
<div>
<p className="text-xs text-gray-400 mb-1">Championship Leader</p>
<p className="font-bold text-white">{drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'}</p>
<p className="text-sm text-yellow-400 font-medium">{leader?.points || 0} points</p>
</div>
</div>
</Card>
<Card>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-primary-blue/10 flex items-center justify-center">
<span className="text-2xl">🏁</span>
</div>
<div>
<p className="text-xs text-gray-400 mb-1">Races Completed</p>
<p className="text-2xl font-bold text-white">{totalRaces}</p>
<p className="text-sm text-gray-400">Season in progress</p>
</div>
</div>
</Card>
<Card>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-performance-green/10 flex items-center justify-center">
<span className="text-2xl">👥</span>
</div>
<div>
<p className="text-xs text-gray-400 mb-1">Active Drivers</p>
<p className="text-2xl font-bold text-white">{standings.length}</p>
<p className="text-sm text-gray-400">Competing for points</p>
</div>
</div>
</Card>
</div>
)}
<LeagueChampionshipStats standings={standings} drivers={drivers} />
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Championship Standings</h2>

View File

@@ -3,6 +3,7 @@
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 '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
@@ -183,29 +184,11 @@ export default function LeagueStewardingPage() {
{/* Stats summary */}
{!loading && stewardingData && (
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-warning-amber mb-1">
<Clock className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Pending Review</span>
</div>
<div className="text-2xl font-bold text-white">{stewardingData.totalPending}</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-performance-green mb-1">
<CheckCircle className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Resolved</span>
</div>
<div className="text-2xl font-bold text-white">{stewardingData.totalResolved}</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-red-400 mb-1">
<Gavel className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Penalties</span>
</div>
<div className="text-2xl font-bold text-white">{stewardingData.totalPenalties}</div>
</div>
</div>
<StewardingStats
totalPending={stewardingData.totalPending}
totalResolved={stewardingData.totalResolved}
totalPenalties={stewardingData.totalPenalties}
/>
)}
{/* Tab navigation */}

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import TransactionRow from '@/components/leagues/TransactionRow';
import { useServices } from '@/lib/services/ServiceProvider';
import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
import {
@@ -21,69 +22,6 @@ import {
Calendar
} from 'lucide-react';
function TransactionRow({ transaction }: { transaction: any }) {
const isIncoming = transaction.amount > 0;
const typeIcons = {
sponsorship: DollarSign,
membership: CreditCard,
withdrawal: ArrowUpRight,
prize: TrendingUp,
};
const TypeIcon = typeIcons[transaction.type];
const statusConfig = {
completed: { color: 'text-performance-green', bg: 'bg-performance-green/10', icon: CheckCircle },
pending: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', icon: Clock },
failed: { color: 'text-racing-red', bg: 'bg-racing-red/10', icon: XCircle },
};
const status = statusConfig[transaction.status];
const StatusIcon = status.icon;
return (
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline last:border-b-0 hover:bg-iron-gray/30 transition-colors">
<div className="flex items-center gap-4">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${isIncoming ? 'bg-performance-green/10' : 'bg-iron-gray/50'}`}>
{isIncoming ? (
<ArrowDownLeft className="w-5 h-5 text-performance-green" />
) : (
<ArrowUpRight className="w-5 h-5 text-gray-400" />
)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-white">{transaction.description}</span>
<span className={`px-2 py-0.5 rounded text-xs ${status.bg} ${status.color}`}>
{transaction.status}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500 mt-1">
<TypeIcon className="w-3 h-3" />
<span className="capitalize">{transaction.type}</span>
{transaction.reference && (
<>
<span></span>
<span>{transaction.reference}</span>
</>
)}
<span></span>
<span>{transaction.formattedDate}</span>
</div>
</div>
</div>
<div className="text-right">
<div className={`font-semibold ${isIncoming ? 'text-performance-green' : 'text-white'}`}>
{transaction.formattedAmount}
</div>
{transaction.fee > 0 && (
<div className="text-xs text-gray-500">
Fee: ${transaction.fee.toFixed(2)}
</div>
)}
</div>
</div>
);
}
export default function LeagueWalletPage() {
const params = useParams();

View File

@@ -3,6 +3,7 @@
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal';
import ImportResultsForm from '@/components/races/ImportResultsForm';
import RaceResultsHeader from '@/components/races/RaceResultsHeader';
import ResultsTable from '@/components/races/ResultsTable';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
@@ -132,50 +133,13 @@ export default function RaceResultsPage() {
</Button>
</div>
<div className="relative overflow-hidden rounded-2xl bg-gray-500/10 border border-gray-500/30 p-6 sm:p-8">
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full blur-3xl" />
<div className="relative z-10">
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-performance-green/10 border border-performance-green/30">
<Trophy className="w-4 h-4 text-performance-green" />
<span className="text-sm font-semibold text-performance-green">
Final Results
</span>
</div>
{raceSOF && (
<span className="flex items-center gap-1.5 text-warning-amber text-sm">
<Zap className="w-4 h-4" />
SOF {raceSOF}
</span>
)}
</div>
<h1 className="text-2xl sm:text-3xl font-bold text-white mb-2">
{raceData?.race?.track ?? 'Race'} Results
</h1>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-gray-400">
{raceData?.race && (
<>
<span className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
{new Date(raceData.race.scheduledAt).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
})}
</span>
<span className="flex items-center gap-2">
<Users className="w-4 h-4" />
{raceData.stats.totalDrivers} drivers classified
</span>
</>
)}
{raceData?.league && <span className="text-primary-blue">{raceData.league.name}</span>}
</div>
</div>
</div>
<RaceResultsHeader
raceTrack={raceData?.race?.track}
raceScheduledAt={raceData?.race?.scheduledAt}
totalDrivers={raceData?.stats.totalDrivers}
leagueName={raceData?.league?.name}
raceSOF={raceSOF}
/>
{importSuccess && (
<div className="p-4 bg-performance-green/10 border border-performance-green/30 rounded-lg text-performance-green">

View File

@@ -1,6 +1,7 @@
'use client';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import RaceStewardingStats from '@/components/races/RaceStewardingStats';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
@@ -154,29 +155,11 @@ export default function RaceStewardingPage() {
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4">
<div className="rounded-lg bg-deep-graphite/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-warning-amber mb-1">
<Clock className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Pending</span>
</div>
<div className="text-2xl font-bold text-white">{stewardingData?.pendingCount ?? 0}</div>
</div>
<div className="rounded-lg bg-deep-graphite/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-performance-green mb-1">
<CheckCircle className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Resolved</span>
</div>
<div className="text-2xl font-bold text-white">{stewardingData?.resolvedCount ?? 0}</div>
</div>
<div className="rounded-lg bg-deep-graphite/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-red-400 mb-1">
<Gavel className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Penalties</span>
</div>
<div className="text-2xl font-bold text-white">{stewardingData?.penaltiesCount ?? 0}</div>
</div>
</div>
<RaceStewardingStats
pendingCount={stewardingData?.pendingCount ?? 0}
resolvedCount={stewardingData?.resolvedCount ?? 0}
penaltiesCount={stewardingData?.penaltiesCount ?? 0}
/>
</Card>
{/* Tab Navigation */}

View File

@@ -6,6 +6,10 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import StatusBadge from '@/components/ui/StatusBadge';
import InfoBanner from '@/components/ui/InfoBanner';
import MetricCard from '@/components/sponsors/MetricCard';
import SponsorshipCategoryCard from '@/components/sponsors/SponsorshipCategoryCard';
import ActivityItem from '@/components/sponsors/ActivityItem';
import RenewalAlert from '@/components/sponsors/RenewalAlert';
import {
BarChart3,
Eye,
@@ -35,146 +39,9 @@ import { SponsorService } from '@/lib/services/sponsors/SponsorService';
import { SponsorDashboardViewModel } from '@/lib/view-models/SponsorDashboardViewModel';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
// Metric Card Component
function MetricCard({
title,
value,
change,
icon: Icon,
suffix = '',
prefix = '',
delay = 0,
}: {
title: string;
value: number | string;
change?: number;
icon: typeof Eye;
suffix?: string;
prefix?: string;
delay?: number;
}) {
const shouldReduceMotion = useReducedMotion();
const isPositive = change && change > 0;
const isNegative = change && change < 0;
return (
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay }}
>
<Card className="p-5 h-full">
<div className="flex items-start justify-between mb-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary-blue/10">
<Icon className="w-5 h-5 text-primary-blue" />
</div>
{change !== undefined && (
<div className={`flex items-center gap-1 text-sm font-medium ${
isPositive ? 'text-performance-green' : isNegative ? 'text-racing-red' : 'text-gray-400'
}`}>
{isPositive ? <ArrowUpRight className="w-4 h-4" /> : isNegative ? <ArrowDownRight className="w-4 h-4" /> : null}
{Math.abs(change)}%
</div>
)}
</div>
<div className="text-2xl font-bold text-white mb-1">
{prefix}{typeof value === 'number' ? value.toLocaleString() : value}{suffix}
</div>
<div className="text-sm text-gray-400">{title}</div>
</Card>
</motion.div>
);
}
// Sponsorship Category Card
function SponsorshipCategoryCard({
icon: Icon,
title,
count,
impressions,
color,
href
}: {
icon: typeof Trophy;
title: string;
count: number;
impressions: number;
color: string;
href: string;
}) {
return (
<Link href={href}>
<Card className="p-4 hover:border-primary-blue/50 transition-all duration-300 cursor-pointer group">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg bg-iron-gray flex items-center justify-center group-hover:bg-primary-blue/10 transition-colors`}>
<Icon className={`w-5 h-5 ${color}`} />
</div>
<div>
<p className="text-white font-medium">{title}</p>
<p className="text-sm text-gray-500">{count} active</p>
</div>
</div>
<div className="text-right">
<p className="text-white font-semibold">{impressions.toLocaleString()}</p>
<p className="text-xs text-gray-500">impressions</p>
</div>
</div>
</Card>
</Link>
);
}
// Activity Item
function ActivityItem({ activity }: { activity: any }) {
return (
<div className="flex items-start gap-3 py-3 border-b border-charcoal-outline/50 last:border-b-0">
<div className={`w-2 h-2 rounded-full mt-2 ${activity.typeColor}`} />
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{activity.message}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-500">{activity.time}</span>
{activity.formattedImpressions && (
<>
<span className="text-xs text-gray-600"></span>
<span className="text-xs text-gray-400">{activity.formattedImpressions} views</span>
</>
)}
</div>
</div>
</div>
);
}
// Renewal Alert
function RenewalAlert({ renewal }: { renewal: any }) {
const typeIcons = {
league: Trophy,
team: Users,
driver: Car,
race: Flag,
platform: Megaphone,
};
const Icon = typeIcons[renewal.type] || Trophy;
return (
<div className="flex items-center justify-between p-3 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
<div className="flex items-center gap-3">
<Icon className="w-4 h-4 text-warning-amber" />
<div>
<p className="text-sm text-white">{renewal.name}</p>
<p className="text-xs text-gray-400">Renews {renewal.formattedRenewDate}</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-semibold text-white">{renewal.formattedPrice}</p>
<Button variant="secondary" className="text-xs mt-1 py-1 px-2 min-h-0">
Renew
</Button>
</div>
</div>
);
}
export default function SponsorDashboardPage() {
const shouldReduceMotion = useReducedMotion();

View File

@@ -12,6 +12,7 @@ import JoinTeamButton from '@/components/teams/JoinTeamButton';
import TeamAdmin from '@/components/teams/TeamAdmin';
import TeamRoster from '@/components/teams/TeamRoster';
import TeamStandings from '@/components/teams/TeamStandings';
import StatItem from '@/components/teams/StatItem';
import { useServices } from '@/lib/services/ServiceProvider';
import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
@@ -319,12 +320,3 @@ export default function TeamDetailPage() {
</div>
);
}
function StatItem({ label, value, color }: { label: string; value: string; color: string }) {
return (
<div className="flex items-center justify-between">
<span className="text-gray-400 text-sm">{label}</span>
<span className={`font-semibold ${color}`}>{value}</span>
</div>
);
}

View File

@@ -22,6 +22,7 @@ import {
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import Heading from '@/components/ui/Heading';
import TopThreePodium from '@/components/teams/TopThreePodium';
import { useAllTeams } from '@/hooks/useTeamService';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
@@ -109,146 +110,6 @@ const SORT_OPTIONS: { id: SortBy; label: string; icon: React.ElementType }[] = [
{ id: 'races', label: 'Races', icon: Hash },
];
// ============================================================================
// TOP THREE PODIUM COMPONENT
// ============================================================================
interface TopThreePodiumProps {
teams: TeamDisplayData[];
onTeamClick: (teamId: string) => void;
}
function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) {
const top3 = teams.slice(0, 3) as [TeamDisplayData, TeamDisplayData, TeamDisplayData];
if (teams.length < 3) return null;
// Display order: 2nd, 1st, 3rd
const podiumOrder: [TeamDisplayData, TeamDisplayData, TeamDisplayData] = [
top3[1],
top3[0],
top3[2],
];
const podiumHeights = ['h-28', 'h-36', 'h-20'];
const podiumPositions = [2, 1, 3];
const getPositionColor = (position: number) => {
switch (position) {
case 1:
return 'text-yellow-400';
case 2:
return 'text-gray-300';
case 3:
return 'text-amber-600';
default:
return 'text-gray-500';
}
};
const getGradient = (position: number) => {
switch (position) {
case 1:
return 'from-yellow-400/30 via-yellow-500/20 to-yellow-600/10';
case 2:
return 'from-gray-300/30 via-gray-400/20 to-gray-500/10';
case 3:
return 'from-amber-500/30 via-amber-600/20 to-amber-700/10';
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 (
<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>
<div className="flex items-end justify-center gap-4 md:gap-8">
{podiumOrder.map((team, index) => {
const position = podiumPositions[index] ?? 0;
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
const LevelIcon = levelConfig?.icon || Shield;
return (
<button
key={team.id}
type="button"
onClick={() => onTeamClick(team.id)}
className="flex flex-col items-center group"
>
{/* 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`}
>
{/* 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>
)}
{/* Team icon */}
<div
className={`flex h-16 w-16 md:h-20 md:w-20 items-center justify-center rounded-xl ${levelConfig?.bgColor} border ${levelConfig?.borderColor} mb-3`}
>
<LevelIcon className={`w-8 h-8 md:w-10 md:h-10 ${levelConfig?.color}`} />
</div>
{/* 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>
{/* Rating */}
<p className={`text-lg md:text-xl font-mono font-bold ${getPositionColor(position)} text-center`}>
{getSafeRating(team).toLocaleString()}
</p>
{/* 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" />
{getSafeTotalWins(team)}
</span>
<span className="flex items-center gap-1">
<Users className="w-3 h-3 text-purple-400" />
{team.memberCount}
</span>
</div>
</div>
{/* 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`}
>
<span className={`text-2xl md:text-3xl font-bold ${getPositionColor(position)}`}>
{position}
</span>
</div>
</button>
);
})}
</div>
</div>
);
}
// ============================================================================
// MAIN PAGE COMPONENT
@@ -455,7 +316,7 @@ export default function TeamLeaderboardPage() {
{/* Podium for Top 3 - only show when viewing by rating without filters */}
{sortBy === 'rating' && filterLevel === 'all' && !searchQuery && filteredAndSortedTeams.length >= 3 && (
<TopThreePodium teams={filteredAndSortedTeams} onTeamClick={handleTeamClick} />
<TopThreePodium teams={filteredAndSortedTeams} onClick={handleTeamClick} />
)}
{/* Stats Summary */}

View File

@@ -28,6 +28,10 @@ import Card from '@/components/ui/Card';
import Input from '@/components/ui/Input';
import Heading from '@/components/ui/Heading';
import CreateTeamForm from '@/components/teams/CreateTeamForm';
import WhyJoinTeamSection from '@/components/teams/WhyJoinTeamSection';
import SkillLevelSection from '@/components/teams/SkillLevelSection';
import FeaturedRecruiting from '@/components/teams/FeaturedRecruiting';
import TeamLeaderboardPreview from '@/components/teams/TeamLeaderboardPreview';
import { useAllTeams } from '@/hooks/useTeamService';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
@@ -88,341 +92,9 @@ const SKILL_LEVELS: {
},
];
// ============================================================================
// WHY JOIN A TEAM SECTION
// ============================================================================
function WhyJoinTeamSection() {
const benefits = [
{
icon: Handshake,
title: 'Shared Strategy',
description: 'Develop setups together, share telemetry, and coordinate pit strategies for endurance races.',
},
{
icon: MessageCircle,
title: 'Team Communication',
description: 'Discord integration, voice chat during races, and dedicated team channels.',
},
{
icon: Calendar,
title: 'Coordinated Schedule',
description: 'Team calendars, practice sessions, and organized race attendance.',
},
{
icon: Trophy,
title: 'Team Championships',
description: 'Compete in team-based leagues and build your collective reputation.',
},
];
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>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{benefits.map((benefit) => (
<div
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"
>
<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>
))}
</div>
</div>
);
}
// ============================================================================
// SKILL LEVEL SECTION COMPONENT
// ============================================================================
interface SkillLevelSectionProps {
level: typeof SKILL_LEVELS[0];
teams: TeamDisplayData[];
onTeamClick: (id: string) => void;
defaultExpanded?: boolean;
}
function SkillLevelSection({ level, teams, onTeamClick, defaultExpanded = false }: SkillLevelSectionProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const recruitingTeams = teams.filter((t) => t.isRecruiting);
const displayedTeams = isExpanded ? teams : teams.slice(0, 3);
const Icon = level.icon;
if (teams.length === 0) return null;
return (
<div className="mb-8">
{/* Section Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`flex h-11 w-11 items-center justify-center rounded-xl ${level.bgColor} border ${level.borderColor}`}>
<Icon className={`w-5 h-5 ${level.color}`} />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white">{level.label}</h2>
<span className="px-2 py-0.5 rounded-full text-xs bg-charcoal-outline/50 text-gray-400">
{teams.length} {teams.length === 1 ? 'team' : 'teams'}
</span>
{recruitingTeams.length > 0 && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-performance-green/10 text-performance-green border border-performance-green/20">
<UserPlus className="w-3 h-3" />
{recruitingTeams.length} recruiting
</span>
)}
</div>
<p className="text-sm text-gray-500">{level.description}</p>
</div>
</div>
{teams.length > 3 && (
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm text-gray-400 hover:text-white hover:bg-iron-gray/50 transition-all"
>
{isExpanded ? 'Show less' : `View all ${teams.length}`}
<ChevronRight className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</button>
)}
</div>
{/* Teams Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{displayedTeams.map((team) => (
<TeamCard
key={team.id}
id={team.id}
name={team.name}
description={team.description ?? ''}
memberCount={team.memberCount}
rating={team.rating}
totalWins={team.totalWins}
totalRaces={team.totalRaces}
performanceLevel={team.performanceLevel}
isRecruiting={team.isRecruiting}
specialization={team.specialization}
region={team.region ?? ''}
languages={team.languages}
onClick={() => onTeamClick(team.id)}
/>
))}
</div>
</div>
);
}
// ============================================================================
// FEATURED RECRUITING TEAMS
// ============================================================================
interface FeaturedRecruitingProps {
teams: TeamDisplayData[];
onTeamClick: (id: string) => void;
}
function FeaturedRecruiting({ teams, onTeamClick }: FeaturedRecruitingProps) {
const recruitingTeams = teams.filter((t) => t.isRecruiting).slice(0, 4);
if (recruitingTeams.length === 0) return null;
return (
<div className="mb-10">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-performance-green/10 border border-performance-green/20">
<UserPlus className="w-5 h-5 text-performance-green" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Looking for Drivers</h2>
<p className="text-xs text-gray-500">Teams actively recruiting new members</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{recruitingTeams.map((team) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
const LevelIcon = levelConfig?.icon || Shield;
return (
<button
key={team.id}
type="button"
onClick={() => onTeamClick(team.id)}
className="p-4 rounded-xl bg-iron-gray/60 border border-charcoal-outline hover:border-performance-green/40 transition-all duration-200 text-left group"
>
<div className="flex items-start justify-between mb-3">
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${levelConfig?.bgColor} border ${levelConfig?.borderColor}`}>
<LevelIcon className={`w-4 h-4 ${levelConfig?.color}`} />
</div>
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] bg-performance-green/10 text-performance-green border border-performance-green/20">
<div className="w-1.5 h-1.5 rounded-full bg-performance-green animate-pulse" />
Recruiting
</span>
</div>
<h3 className="text-white font-semibold mb-1 group-hover:text-performance-green transition-colors line-clamp-1">
{team.name}
</h3>
<p className="text-xs text-gray-500 line-clamp-2 mb-3">{team.description}</p>
<div className="flex items-center gap-3 text-xs text-gray-400">
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{team.memberCount}
</span>
<span className="flex items-center gap-1">
<Trophy className="w-3 h-3" />
{team.totalWins} wins
</span>
</div>
</button>
);
})}
</div>
</div>
);
}
// ============================================================================
// TEAM LEADERBOARD PREVIEW COMPONENT (Top 5 + Link)
// ============================================================================
interface TeamLeaderboardPreviewProps {
topTeams: TeamDisplayData[];
onTeamClick: (id: string) => void;
}
function TeamLeaderboardPreview({ topTeams, onTeamClick }: TeamLeaderboardPreviewProps) {
const router = useRouter();
const getMedalColor = (position: number) => {
switch (position) {
case 0:
return 'text-yellow-400';
case 1:
return 'text-gray-300';
case 2:
return 'text-amber-600';
default:
return 'text-gray-500';
}
};
const getMedalBg = (position: number) => {
switch (position) {
case 0:
return 'bg-yellow-400/10 border-yellow-400/30';
case 1:
return 'bg-gray-300/10 border-gray-300/30';
case 2:
return 'bg-amber-600/10 border-amber-600/30';
default:
return 'bg-iron-gray/50 border-charcoal-outline';
}
};
if (topTeams.length === 0) return null;
return (
<div className="mb-12">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-yellow-400/20 to-amber-600/10 border border-yellow-400/30">
<Award className="w-5 h-5 text-yellow-400" />
</div>
<div>
<h2 className="text-xl font-bold text-white">Top Teams</h2>
<p className="text-sm text-gray-500">Highest rated racing teams</p>
</div>
</div>
<Button
variant="secondary"
onClick={() => router.push('/teams/leaderboard')}
className="flex items-center gap-2 text-sm"
>
View Full Leaderboard
<ChevronRight className="w-4 h-4" />
</Button>
</div>
{/* Compact Leaderboard */}
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
<div className="divide-y divide-charcoal-outline/50">
{topTeams.map((team, index) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
const LevelIcon = levelConfig?.icon || Shield;
return (
<button
key={team.id}
type="button"
onClick={() => onTeamClick(team.id)}
className="flex items-center gap-4 px-4 py-3 w-full text-left hover:bg-iron-gray/30 transition-colors group"
>
{/* Position */}
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold border ${getMedalBg(index)} ${getMedalColor(index)}`}
>
{index < 3 ? (
<Crown className="w-3.5 h-3.5" />
) : (
index + 1
)}
</div>
{/* Team Info */}
<div className={`flex h-9 w-9 items-center justify-center rounded-lg ${levelConfig?.bgColor} border ${levelConfig?.borderColor}`}>
<LevelIcon className={`w-4 h-4 ${levelConfig?.color}`} />
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate group-hover:text-purple-400 transition-colors">
{team.name}
</p>
<div className="flex items-center gap-3 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{team.memberCount}
</span>
<span className="flex items-center gap-1">
<Trophy className="w-3 h-3" />
{team.totalWins} wins
</span>
{team.isRecruiting && (
<span className="flex items-center gap-1 text-performance-green">
<div className="w-1.5 h-1.5 rounded-full bg-performance-green animate-pulse" />
Recruiting
</span>
)}
</div>
</div>
{/* Rating */}
<div className="text-right">
<p className="text-purple-400 font-mono font-semibold">
{team.rating?.toLocaleString()}
</p>
<p className="text-xs text-gray-500">Rating</p>
</div>
</button>
);
})}
</div>
</div>
</div>
);
}
// ============================================================================
// MAIN PAGE COMPONENT

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { Trophy, Medal, Star, Crown, Target, Zap } from 'lucide-react';
import type { DriverProfileAchievementViewModel } from '@/lib/view-models/DriverProfileViewModel';
interface AchievementCardProps {
achievement: DriverProfileAchievementViewModel;
}
function getRarityColor(rarity: DriverProfileAchievementViewModel['rarity']) {
switch (rarity) {
case 'common':
return 'text-gray-400 bg-gray-400/10 border-gray-400/30';
case 'rare':
return 'text-primary-blue bg-primary-blue/10 border-primary-blue/30';
case 'epic':
return 'text-purple-400 bg-purple-400/10 border-purple-400/30';
case 'legendary':
return 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30';
}
}
function getAchievementIcon(icon: DriverProfileAchievementViewModel['icon']) {
switch (icon) {
case 'trophy':
return Trophy;
case 'medal':
return Medal;
case 'star':
return Star;
case 'crown':
return Crown;
case 'target':
return Target;
case 'zap':
return Zap;
}
}
export default function AchievementCard({ achievement }: AchievementCardProps) {
const Icon = getAchievementIcon(achievement.icon);
const rarityClasses = getRarityColor(achievement.rarity);
return (
<div className={`p-4 rounded-xl border ${rarityClasses} transition-all hover:scale-105`}>
<div className="flex items-start gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${rarityClasses.split(' ')[1]}`}>
<Icon className={`w-5 h-5 ${rarityClasses.split(' ')[0]}`} />
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-semibold text-sm">{achievement.title}</p>
<p className="text-gray-400 text-xs mt-0.5">{achievement.description}</p>
<p className="text-gray-500 text-xs mt-1">
{new Date(achievement.earnedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { motion } from 'framer-motion';
import { Car, Trophy, Users } from 'lucide-react';
const USER_ROLES = [
{
icon: Car,
title: 'Driver',
description: 'Race, track stats, join teams',
color: 'primary-blue',
},
{
icon: Trophy,
title: 'League Admin',
description: 'Organize leagues and events',
color: 'performance-green',
},
{
icon: Users,
title: 'Team Manager',
description: 'Manage team and drivers',
color: 'purple-400',
},
];
interface UserRolesPreviewProps {
variant?: 'full' | 'compact';
}
export default 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">
{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>
))}
</div>
</div>
);
}
return (
<div className="space-y-3 mb-8">
{USER_ROLES.map((role, index) => (
<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"
>
<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>
))}
</div>
);
}

View File

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

View File

@@ -0,0 +1,45 @@
import React from 'react';
interface FinishDistributionProps {
wins: number;
podiums: number;
topTen: number;
total: number;
}
export default function FinishDistributionChart({ wins, podiums, topTen, total }: FinishDistributionProps) {
const outsideTopTen = total - topTen;
const podiumsNotWins = podiums - wins;
const topTenNotPodium = topTen - podiums;
const segments = [
{ label: 'Wins', value: wins, color: 'bg-performance-green', textColor: 'text-performance-green' },
{ label: 'Podiums', value: podiumsNotWins, color: 'bg-warning-amber', textColor: 'text-warning-amber' },
{ label: 'Top 10', value: topTenNotPodium, color: 'bg-primary-blue', textColor: 'text-primary-blue' },
{ label: 'Other', value: outsideTopTen, color: 'bg-gray-600', textColor: 'text-gray-400' },
].filter(s => s.value > 0);
return (
<div className="space-y-3">
<div className="h-4 rounded-full overflow-hidden flex bg-charcoal-outline">
{segments.map((segment, index) => (
<div
key={segment.label}
className={`${segment.color} transition-all duration-500`}
style={{ width: `${(segment.value / total) * 100}%` }}
/>
))}
</div>
<div className="flex flex-wrap gap-4 justify-center">
{segments.map((segment) => (
<div key={segment.label} className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${segment.color}`} />
<span className={`text-xs ${segment.textColor}`}>
{segment.label}: {segment.value} ({((segment.value / total) * 100).toFixed(0)}%)
</span>
</div>
))}
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,43 @@
import { Activity, Trophy, Medal, UserPlus, Heart, Flag, Play } from 'lucide-react';
import Button from '@/components/ui/Button';
import Link from 'next/link';
import { timeAgo } from '@/lib/utilities/time';
import { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
function FeedItemRow({ item }: { item: DashboardFeedItemSummaryViewModel }) {
const getActivityIcon = (type: string) => {
if (type.includes('win')) return { icon: Trophy, color: 'text-yellow-400 bg-yellow-400/10' };
if (type.includes('podium')) return { icon: Medal, color: 'text-warning-amber bg-warning-amber/10' };
if (type.includes('join')) return { icon: UserPlus, color: 'text-performance-green bg-performance-green/10' };
if (type.includes('friend')) return { icon: Heart, color: 'text-pink-400 bg-pink-400/10' };
if (type.includes('league')) return { icon: Flag, color: 'text-primary-blue bg-primary-blue/10' };
if (type.includes('race')) return { icon: Play, color: 'text-red-400 bg-red-400/10' };
return { icon: Activity, color: 'text-gray-400 bg-gray-400/10' };
};
const { icon: Icon, color } = getActivityIcon(item.type);
return (
<div className="flex gap-3 p-3 rounded-lg bg-deep-graphite/50 border border-charcoal-outline">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${color} flex-shrink-0`}>
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-white">{item.headline}</p>
{item.body && (
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{item.body}</p>
)}
<p className="text-xs text-gray-500 mt-1">{timeAgo(item.timestamp)}</p>
</div>
{item.ctaHref && (
<Link href={item.ctaHref} className="flex-shrink-0">
<Button variant="secondary" className="text-xs px-3 py-1.5">
{item.ctaLabel || 'View'}
</Button>
</Link>
)}
</div>
);
}
export { FeedItemRow };

View File

@@ -0,0 +1,33 @@
import Link from 'next/link';
import Image from 'next/image';
import { getCountryFlag } from '@/lib/utilities/country';
interface FriendItemProps {
id: string;
name: string;
avatarUrl: string;
country: string;
}
export function FriendItem({ id, name, avatarUrl, country }: FriendItemProps) {
return (
<Link
href={`/drivers/${id}`}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite transition-colors"
>
<div className="w-9 h-9 rounded-full overflow-hidden bg-gradient-to-br from-primary-blue to-purple-600">
<Image
src={avatarUrl}
alt={name}
width={36}
height={36}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate">{name}</p>
<p className="text-xs text-gray-500">{getCountryFlag(country)}</p>
</div>
</Link>
);
}

View File

@@ -0,0 +1,54 @@
import Link from 'next/link';
import { Crown, ChevronRight } from 'lucide-react';
interface LeagueStandingItemProps {
leagueId: string;
leagueName: string;
position: number;
points: number;
totalDrivers: number;
className?: string;
}
export function LeagueStandingItem({
leagueId,
leagueName,
position,
points,
totalDrivers,
className,
}: LeagueStandingItemProps) {
return (
<Link
href={`/leagues/${leagueId}/standings`}
className={`flex items-center gap-4 p-4 rounded-xl bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/30 transition-colors group ${className || ''}`}
>
<div className={`flex h-12 w-12 items-center justify-center rounded-xl font-bold text-xl ${
position === 1 ? 'bg-yellow-400/20 text-yellow-400' :
position === 2 ? 'bg-gray-300/20 text-gray-300' :
position === 3 ? 'bg-orange-400/20 text-orange-400' :
'bg-iron-gray text-gray-400'
}`}>
{position > 0 ? `P${position}` : '-'}
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-semibold truncate group-hover:text-primary-blue transition-colors">
{leagueName}
</p>
<p className="text-sm text-gray-500">
{points} points {totalDrivers} drivers
</p>
</div>
<div className="flex items-center gap-2">
{position <= 3 && position > 0 && (
<Crown className={`w-5 h-5 ${
position === 1 ? 'text-yellow-400' :
position === 2 ? 'text-gray-300' :
'text-orange-400'
}`} />
)}
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-primary-blue transition-colors" />
</div>
</Link>
);
}

View File

@@ -0,0 +1,25 @@
import { LucideIcon } from 'lucide-react';
interface StatCardProps {
icon: LucideIcon;
value: string | number;
label: string;
color: string;
className?: string;
}
export function StatCard({ icon: Icon, value, label, color, className }: StatCardProps) {
return (
<div className={`p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm ${className || ''}`}>
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${color}`}>
<Icon className="w-5 h-5" />
</div>
<div>
<p className="text-2xl font-bold text-white">{value}</p>
<p className="text-xs text-gray-500">{label}</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import Link from 'next/link';
import { timeUntil } from '@/lib/utilities/time';
interface UpcomingRaceItemProps {
id: string;
track: string;
car: string;
scheduledAt: Date;
isMyLeague: boolean;
}
export function UpcomingRaceItem({
id,
track,
car,
scheduledAt,
isMyLeague,
}: UpcomingRaceItemProps) {
return (
<Link
href={`/races/${id}`}
className="block p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/30 transition-colors"
>
<div className="flex items-start justify-between gap-2 mb-2">
<p className="text-white font-medium text-sm truncate">{track}</p>
{isMyLeague && (
<span className="flex-shrink-0 w-2 h-2 rounded-full bg-performance-green" title="Your league" />
)}
</div>
<p className="text-xs text-gray-500 truncate mb-2">{car}</p>
<div className="flex items-center justify-between text-xs">
<span className="text-gray-400">
{scheduledAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
<span className="text-primary-blue font-medium">{timeUntil(scheduledAt)}</span>
</div>
</Link>
);
}

View File

@@ -0,0 +1,84 @@
import { Users, Trophy, ChevronRight } from 'lucide-react';
import Heading from '@/components/ui/Heading';
import Button from '@/components/ui/Button';
interface HeroSectionProps {
icon?: React.ElementType;
title: string;
description: string;
stats: Array<{
value: number | string;
label: string;
color: string;
animate?: boolean;
}>;
ctaLabel?: string;
ctaDescription?: string;
onCtaClick?: () => void;
className?: string;
}
export function HeroSection({
icon: Icon = Users,
title,
description,
stats,
ctaLabel = "View Leaderboard",
ctaDescription = "See full driver rankings",
onCtaClick,
className,
}: HeroSectionProps) {
return (
<div className={`relative mb-10 py-10 px-8 rounded-2xl bg-gradient-to-br from-primary-blue/20 via-iron-gray/80 to-deep-graphite border border-primary-blue/30 overflow-hidden ${className || ''}`}>
{/* Background decoration */}
<div className="absolute top-0 right-0 w-96 h-96 bg-primary-blue/10 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-64 h-64 bg-yellow-400/5 rounded-full blur-3xl" />
<div className="absolute top-1/2 right-1/4 w-48 h-48 bg-performance-green/5 rounded-full blur-2xl" />
<div className="relative z-10 flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8">
<div className="max-w-2xl">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/20">
<Icon className="w-6 h-6 text-primary-blue" />
</div>
<Heading level={1} className="text-3xl lg:text-4xl">
{title}
</Heading>
</div>
<p className="text-gray-400 text-lg leading-relaxed mb-6">
{description}
</p>
{/* Quick Stats */}
<div className="flex flex-wrap gap-6">
{stats.map((stat, index) => (
<div key={index} className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${stat.color} ${stat.animate ? 'animate-pulse' : ''}`} />
<span className="text-sm text-gray-400">
<span className="text-white font-semibold">
{typeof stat.value === 'number' ? stat.value.toLocaleString() : stat.value}
</span> {stat.label}
</span>
</div>
))}
</div>
</div>
{/* CTA */}
{onCtaClick && (
<div className="flex flex-col gap-4">
<Button
variant="primary"
onClick={onCtaClick}
className="flex items-center gap-2 px-6 py-3"
>
<Trophy className="w-5 h-5" />
{ctaLabel}
</Button>
<p className="text-xs text-gray-500 text-center">{ctaDescription}</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { LucideIcon } from 'lucide-react';
interface FeatureItemProps {
icon: LucideIcon;
text: string;
className?: string;
}
export function FeatureItem({ icon: Icon, text, className }: FeatureItemProps) {
return (
<div className={`group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)] ${className || ''}`}>
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<Icon className="w-5 h-5 text-primary-blue" />
</div>
<span className="text-slate-200 leading-relaxed font-light">
{text}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { Trophy, Sparkles, Search } from 'lucide-react';
import Heading from '@/components/ui/Heading';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
interface EmptyStateProps {
title: string;
description: string;
icon?: React.ElementType;
actionIcon?: React.ElementType;
actionLabel?: string;
onAction?: () => void;
children?: React.ReactNode;
className?: string;
}
export function EmptyState({
title,
description,
icon: Icon = Trophy,
actionIcon: 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>
);
}

View File

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

View File

@@ -0,0 +1,59 @@
import React from 'react';
import Card from '@/components/ui/Card';
import { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
import { DriverViewModel } from '@/lib/view-models';
interface LeagueChampionshipStatsProps {
standings: StandingEntryViewModel[];
drivers: DriverViewModel[];
}
export default function LeagueChampionshipStats({ standings, drivers }: LeagueChampionshipStatsProps) {
if (standings.length === 0) return null;
const leader = standings[0];
const totalRaces = Math.max(...standings.map(s => s.races), 0);
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-yellow-500/10 flex items-center justify-center">
<span className="text-2xl">🏆</span>
</div>
<div>
<p className="text-xs text-gray-400 mb-1">Championship Leader</p>
<p className="font-bold text-white">{drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'}</p>
<p className="text-sm text-yellow-400 font-medium">{leader?.points || 0} points</p>
</div>
</div>
</Card>
<Card>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-primary-blue/10 flex items-center justify-center">
<span className="text-2xl">🏁</span>
</div>
<div>
<p className="text-xs text-gray-400 mb-1">Races Completed</p>
<p className="text-2xl font-bold text-white">{totalRaces}</p>
<p className="text-sm text-gray-400">Season in progress</p>
</div>
</div>
</Card>
<Card>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-performance-green/10 flex items-center justify-center">
<span className="text-2xl">👥</span>
</div>
<div>
<p className="text-xs text-gray-400 mb-1">Active Drivers</p>
<p className="text-2xl font-bold text-white">{standings.length}</p>
<p className="text-sm text-gray-400">Competing for points</p>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import React, { useState } from 'react';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
import Button from '@/components/ui/Button';
import { UserCog } from 'lucide-react';
import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel';
interface LeagueOwnershipTransferProps {
settings: LeagueSettingsViewModel;
currentDriverId: string;
onTransferOwnership: (newOwnerId: string) => Promise<void>;
}
export default function LeagueOwnershipTransfer({
settings,
currentDriverId,
onTransferOwnership
}: LeagueOwnershipTransferProps) {
const [showTransferDialog, setShowTransferDialog] = useState(false);
const [selectedNewOwner, setSelectedNewOwner] = useState<string>('');
const [transferring, setTransferring] = useState(false);
const handleTransferOwnership = async () => {
if (!selectedNewOwner) return;
setTransferring(true);
try {
await onTransferOwnership(selectedNewOwner);
setShowTransferDialog(false);
setSelectedNewOwner('');
} catch (err) {
console.error('Failed to transfer ownership:', err);
alert(err instanceof Error ? err.message : 'Failed to transfer ownership');
} finally {
setTransferring(false);
}
};
const ownerSummary = settings.owner;
return (
<div className="space-y-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>
{ownerSummary ? (
<DriverSummaryPill
driver={ownerSummary.driver}
rating={ownerSummary.rating}
rank={ownerSummary.rank}
/>
) : (
<p className="text-sm text-gray-500">Loading owner details...</p>
)}
</div>
{/* 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>
{!showTransferDialog ? (
<Button
variant="secondary"
onClick={() => setShowTransferDialog(true)}
>
Transfer Ownership
</Button>
) : (
<div className="space-y-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>
<div className="flex gap-2">
<Button
variant="primary"
onClick={handleTransferOwnership}
disabled={!selectedNewOwner || transferring}
>
{transferring ? 'Transferring...' : 'Confirm Transfer'}
</Button>
<Button
variant="secondary"
onClick={() => {
setShowTransferDialog(false);
setSelectedNewOwner('');
}}
disabled={transferring}
>
Cancel
</Button>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,204 @@
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="flex gap-4 overflow-x-auto pb-4 px-4"
style={{
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
>
<style jsx>{`
div::-webkit-scrollbar {
display: none;
}
`}</style>
{leagues.map((league) => (
<div key={league.id} className="flex-shrink-0 w-[320px] h-full">
<LeagueCard league={league} onClick={() => onLeagueClick(league.id)} />
</div>
))}
</div>
</div>
</div>
);
};

View File

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

View File

@@ -0,0 +1,55 @@
import React from 'react';
import Card from '@/components/ui/Card';
interface PointsTableProps {
title?: string;
points: { position: number; points: number }[];
}
export default function PointsTable({ title = 'Points Distribution', points }: PointsTableProps) {
return (
<Card>
<h2 className="text-lg font-semibold text-white mb-4">{title}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 font-medium text-gray-400">Position</th>
<th className="text-right py-3 px-4 font-medium text-gray-400">Points</th>
</tr>
</thead>
<tbody>
{points.map(({ position, points: pts }) => (
<tr
key={position}
className={`border-b border-charcoal-outline/50 transition-colors hover:bg-iron-gray/30 ${
position <= 3 ? 'bg-iron-gray/20' : ''
}`}
>
<td className="py-3 px-4">
<div className="flex items-center gap-3">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold ${
position === 1 ? 'bg-yellow-500 text-black' :
position === 2 ? 'bg-gray-400 text-black' :
position === 3 ? 'bg-amber-600 text-white' :
'bg-charcoal-outline text-white'
}`}>
{position}
</div>
<span className="text-white font-medium">
{position === 1 ? '1st' : position === 2 ? '2nd' : position === 3 ? '3rd' : `${position}th`}
</span>
</div>
</td>
<td className="py-3 px-4 text-right">
<span className="text-white font-semibold tabular-nums">{pts}</span>
<span className="text-gray-500 ml-1">pts</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
}

View File

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

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { CheckCircle, Clock, Gavel } from 'lucide-react';
interface StewardingStatsProps {
totalPending: number;
totalResolved: number;
totalPenalties: number;
}
export default function StewardingStats({ totalPending, totalResolved, totalPenalties }: StewardingStatsProps) {
return (
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-warning-amber mb-1">
<Clock className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Pending Review</span>
</div>
<div className="text-2xl font-bold text-white">{totalPending}</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-performance-green mb-1">
<CheckCircle className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Resolved</span>
</div>
<div className="text-2xl font-bold text-white">{totalResolved}</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-red-400 mb-1">
<Gavel className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Penalties</span>
</div>
<div className="text-2xl font-bold text-white">{totalPenalties}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import React from 'react';
import {
ArrowDownLeft,
ArrowUpRight,
CheckCircle,
Clock,
CreditCard,
DollarSign,
TrendingUp,
XCircle
} from 'lucide-react';
interface Transaction {
id: string;
amount: number;
type: 'sponsorship' | 'membership' | 'withdrawal' | 'prize';
status: 'completed' | 'pending' | 'failed';
description: string;
reference?: string;
formattedDate: string;
formattedAmount: string;
fee: number;
}
interface TransactionRowProps {
transaction: Transaction;
}
export default function TransactionRow({ transaction }: TransactionRowProps) {
const isIncoming = transaction.amount > 0;
const typeIcons = {
sponsorship: DollarSign,
membership: CreditCard,
withdrawal: ArrowUpRight,
prize: TrendingUp,
};
const TypeIcon = typeIcons[transaction.type];
const statusConfig = {
completed: { color: 'text-performance-green', bg: 'bg-performance-green/10', icon: CheckCircle },
pending: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', icon: Clock },
failed: { color: 'text-racing-red', bg: 'bg-racing-red/10', icon: XCircle },
};
const status = statusConfig[transaction.status];
const StatusIcon = status.icon;
return (
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline last:border-b-0 hover:bg-iron-gray/30 transition-colors">
<div className="flex items-center gap-4">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${isIncoming ? 'bg-performance-green/10' : 'bg-iron-gray/50'}`}>
{isIncoming ? (
<ArrowDownLeft className="w-5 h-5 text-performance-green" />
) : (
<ArrowUpRight className="w-5 h-5 text-gray-400" />
)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-white">{transaction.description}</span>
<span className={`px-2 py-0.5 rounded text-xs ${status.bg} ${status.color}`}>
{transaction.status}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500 mt-1">
<TypeIcon className="w-3 h-3" />
<span className="capitalize">{transaction.type}</span>
{transaction.reference && (
<>
<span></span>
<span>{transaction.reference}</span>
</>
)}
<span></span>
<span>{transaction.formattedDate}</span>
</div>
</div>
</div>
<div className="text-right">
<div className={`font-semibold ${isIncoming ? 'text-performance-green' : 'text-white'}`}>
{transaction.formattedAmount}
</div>
{transaction.fee > 0 && (
<div className="text-xs text-gray-500">
Fee: ${transaction.fee.toFixed(2)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Car, Download, Trash2, Edit } from 'lucide-react';
interface DriverLiveryItem {
id: string;
carId: string;
carName: string;
thumbnailUrl: string;
uploadedAt: Date;
isValidated: boolean;
}
interface LiveryCardProps {
livery: DriverLiveryItem;
onEdit?: (id: string) => void;
onDownload?: (id: string) => void;
onDelete?: (id: string) => void;
}
export default 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>
{/* Livery Info */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-white">{livery.carName}</h3>
{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">
Validated
</span>
) : (
<span className="px-2 py-0.5 text-xs bg-warning-amber/10 text-warning-amber border border-warning-amber/30 rounded-full">
Pending
</span>
)}
</div>
<p className="text-xs text-gray-500">
Uploaded {new Date(livery.uploadedAt).toLocaleDateString()}
</p>
{/* Actions */}
<div className="flex gap-2 pt-2">
<Button
variant="secondary"
className="flex-1 px-3 py-1.5"
onClick={() => onEdit?.(livery.id)}
>
<Edit className="w-4 h-4 mr-1" />
Edit
</Button>
<Button
variant="secondary"
className="px-3 py-1.5"
onClick={() => onDownload?.(livery.id)}
>
<Download className="w-4 h-4" />
</Button>
<Button
variant="danger"
className="px-3 py-1.5"
onClick={() => onDelete?.(livery.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,51 @@
import { ChevronRight, PlayCircle } from 'lucide-react';
interface LiveRaceBannerProps {
liveRaces: Array<{
id: string;
track: string;
leagueName: string;
}>;
onRaceClick?: (raceId: string) => void;
className?: string;
}
export function LiveRaceBanner({ liveRaces, onRaceClick, className }: LiveRaceBannerProps) {
if (liveRaces.length === 0) return null;
return (
<div className={`relative overflow-hidden rounded-xl bg-gradient-to-r from-performance-green/20 via-performance-green/10 to-transparent border border-performance-green/30 p-6 ${className || ''}`}>
<div className="absolute top-0 right-0 w-32 h-32 bg-performance-green/20 rounded-full blur-2xl animate-pulse" />
<div className="relative z-10">
<div className="flex items-center gap-2 mb-4">
<div className="flex items-center gap-2 px-3 py-1 bg-performance-green/20 rounded-full">
<span className="w-2 h-2 bg-performance-green rounded-full animate-pulse" />
<span className="text-performance-green font-semibold text-sm">LIVE NOW</span>
</div>
</div>
<div className="space-y-3">
{liveRaces.map((race) => (
<div
key={race.id}
onClick={() => onRaceClick?.(race.id)}
className="flex items-center justify-between p-4 bg-deep-graphite/80 rounded-lg border border-performance-green/20 cursor-pointer hover:border-performance-green/40 transition-all"
>
<div className="flex items-center gap-4">
<div className="p-2 bg-performance-green/20 rounded-lg">
<PlayCircle className="w-5 h-5 text-performance-green" />
</div>
<div>
<h3 className="font-semibold text-white">{race.track}</h3>
<p className="text-sm text-gray-400">{race.leagueName}</p>
</div>
</div>
<ChevronRight className="w-5 h-5 text-gray-400" />
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import Link from 'next/link';
import { Users, Trophy, ChevronRight } from 'lucide-react';
export function QuickActions({ className }: { className?: string }) {
return (
<div className={className}>
<h3 className="font-semibold text-white mb-4">Quick Actions</h3>
<div className="space-y-2">
<Link
href="/leagues"
className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite hover:bg-charcoal-outline/50 transition-colors"
>
<div className="p-2 bg-primary-blue/10 rounded-lg">
<Users className="w-4 h-4 text-primary-blue" />
</div>
<span className="text-sm text-white">Browse Leagues</span>
<ChevronRight className="w-4 h-4 text-gray-500 ml-auto" />
</Link>
<Link
href="/leaderboards"
className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite hover:bg-charcoal-outline/50 transition-colors"
>
<div className="p-2 bg-warning-amber/10 rounded-lg">
<Trophy className="w-4 h-4 text-warning-amber" />
</div>
<span className="text-sm text-white">View Leaderboards</span>
<ChevronRight className="w-4 h-4 text-gray-500 ml-auto" />
</Link>
</div>
</div>
);
}

View File

@@ -1,185 +1,98 @@
'use client';
import { Race } from '@core/racing/domain/entities/Race';
import { Clock, PlayCircle, CheckCircle2, XCircle, Zap, Car, Trophy } from 'lucide-react';
import Link from 'next/link';
import { ChevronRight, Car, Zap, Trophy, ArrowRight } from 'lucide-react';
import { formatTime, getRelativeTime } from '@/lib/utilities/time';
import { raceStatusConfig } from '@/lib/utilities/raceStatus';
interface RaceCardProps {
race: Race;
leagueName?: string;
race: {
id: string;
track: string;
car: string;
scheduledAt: string;
status: string;
leagueId?: string;
leagueName: string;
strengthOfField?: number | null;
};
onClick?: () => void;
compact?: boolean;
className?: string;
}
export default function RaceCard({ race, leagueName, onClick, compact = false }: RaceCardProps) {
const statusConfig = {
scheduled: {
icon: Clock,
color: 'text-primary-blue',
bg: 'bg-primary-blue/10',
border: 'border-primary-blue/30',
label: 'Scheduled',
},
running: {
icon: PlayCircle,
color: 'text-performance-green',
bg: 'bg-performance-green/10',
border: 'border-performance-green/30',
label: 'LIVE',
},
completed: {
icon: CheckCircle2,
color: 'text-gray-400',
bg: 'bg-gray-500/10',
border: 'border-gray-500/30',
label: 'Completed',
},
cancelled: {
icon: XCircle,
color: 'text-warning-amber',
bg: 'bg-warning-amber/10',
border: 'border-warning-amber/30',
label: 'Cancelled',
},
};
const config = statusConfig[race.status];
const StatusIcon = config.icon;
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const formatTime = (date: Date) => {
return new Date(date).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short',
});
};
const getRelativeTime = (date: Date) => {
const now = new Date();
const targetDate = new Date(date);
const diffMs = targetDate.getTime() - now.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMs < 0) return null;
if (diffHours < 1) return 'Starting soon';
if (diffHours < 24) return `In ${diffHours}h`;
if (diffDays === 1) return 'Tomorrow';
if (diffDays < 7) return `In ${diffDays} days`;
return null;
};
const relativeTime = race.status === 'scheduled' ? getRelativeTime(race.scheduledAt) : null;
if (compact) {
return (
<div
onClick={onClick}
className={`
relative overflow-hidden p-4 rounded-lg bg-iron-gray border ${config.border}
transition-all duration-200
${onClick ? 'cursor-pointer hover:scale-[1.02] hover:border-primary-blue' : ''}
`}
>
{race.status === 'running' && (
<div className="absolute top-0 left-0 right-0 h-0.5 bg-performance-green animate-pulse" />
)}
<div className="flex items-center gap-3">
<div className="flex-shrink-0">
<StatusIcon className={`w-5 h-5 ${config.color}`} />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-white truncate">{race.track}</h3>
<p className="text-xs text-gray-500">{formatTime(race.scheduledAt)}</p>
</div>
{relativeTime && (
<span className={`text-xs ${config.color}`}>{relativeTime}</span>
)}
</div>
</div>
);
}
export function RaceCard({ race, onClick, className }: RaceCardProps) {
const config = raceStatusConfig[race.status as keyof typeof raceStatusConfig];
return (
<div
onClick={onClick}
className={`
relative overflow-hidden p-5 rounded-xl bg-iron-gray border ${config.border}
transition-all duration-200
${onClick ? 'cursor-pointer hover:scale-[1.02] hover:border-primary-blue' : ''}
`}
className={`group relative overflow-hidden rounded-xl bg-iron-gray border ${config.border} p-4 cursor-pointer transition-all duration-200 hover:scale-[1.01] hover:border-primary-blue ${className || ''}`}
>
{/* Live indicator bar */}
{/* Live indicator */}
{race.status === 'running' && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-performance-green via-performance-green/50 to-performance-green animate-pulse" />
)}
<div className="flex items-start justify-between gap-4">
{/* Left side - Race info */}
<div className="flex items-start gap-4">
{/* Time Column */}
<div className="flex-shrink-0 text-center min-w-[60px]">
<p className="text-lg font-bold text-white">
{formatTime(race.scheduledAt)}
</p>
<p className={`text-xs ${config.color}`}>
{race.status === 'running'
? 'LIVE'
: getRelativeTime(race.scheduledAt)}
</p>
</div>
{/* Divider */}
<div className={`w-px self-stretch ${config.bg}`} />
{/* Main Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h3 className="text-lg font-semibold text-white truncate">{race.track}</h3>
{/* Status badge */}
<div className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full ${config.bg} border ${config.border} flex-shrink-0`}>
{race.status === 'running' && (
<span className="w-1.5 h-1.5 bg-performance-green rounded-full animate-pulse" />
)}
<StatusIcon className={`w-3.5 h-3.5 ${config.color}`} />
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h3 className="font-semibold text-white truncate group-hover:text-primary-blue transition-colors">
{race.track}
</h3>
<div className="flex items-center gap-3 mt-1">
<span className="flex items-center gap-1 text-sm text-gray-400">
<Car className="w-3.5 h-3.5" />
{race.car}
</span>
{race.strengthOfField && (
<span className="flex items-center gap-1 text-sm text-gray-400">
<Zap className="w-3.5 h-3.5 text-warning-amber" />
SOF {race.strengthOfField}
</span>
)}
</div>
</div>
{/* Status Badge */}
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full ${config.bg} ${config.border} border`}>
<config.icon className={`w-3.5 h-3.5 ${config.color}`} />
<span className={`text-xs font-medium ${config.color}`}>
{config.label}
</span>
</div>
</div>
{/* Meta info */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-400">
<span className="flex items-center gap-1.5">
<Car className="w-3.5 h-3.5" />
{race.car}
</span>
{race.strengthOfField && (
<span className="flex items-center gap-1.5 text-warning-amber">
<Zap className="w-3.5 h-3.5" />
SOF {race.strengthOfField}
</span>
)}
{leagueName && (
<span className="flex items-center gap-1.5 text-primary-blue">
<Trophy className="w-3.5 h-3.5" />
{leagueName}
</span>
)}
{/* League Link */}
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
<Link
href={`/leagues/${race.leagueId ?? ''}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-2 text-sm text-primary-blue hover:underline"
>
<Trophy className="w-3.5 h-3.5" />
{race.leagueName}
<ArrowRight className="w-3 h-3" />
</Link>
</div>
</div>
{/* Right side - Date/Time */}
<div className="text-right flex-shrink-0">
<p className="text-white font-medium">{formatDate(race.scheduledAt)}</p>
<p className="text-gray-500 text-sm">{formatTime(race.scheduledAt)}</p>
{relativeTime && (
<p className={`text-sm mt-1 ${config.color}`}>{relativeTime}</p>
)}
</div>
</div>
{/* Bottom row */}
<div className="flex items-center justify-between mt-3 pt-3 border-t border-charcoal-outline/50">
<span className="text-xs text-gray-500 uppercase tracking-wide">
{race.sessionType}
</span>
{race.registeredCount !== undefined && (
<span className="text-xs text-gray-500">
{race.registeredCount} registered
{race.maxParticipants && ` / ${race.maxParticipants}`}
</span>
)}
{/* Arrow */}
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-primary-blue transition-colors flex-shrink-0" />
</div>
</div>
);

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { Calendar, Trophy, Users, Zap } from 'lucide-react';
interface RaceResultsHeaderProps {
raceTrack?: string;
raceScheduledAt?: string;
totalDrivers?: number;
leagueName?: string;
raceSOF?: number | null;
}
const DEFAULT_RACE_TRACK = 'Race';
export default function RaceResultsHeader({
raceTrack = 'Race',
raceScheduledAt,
totalDrivers,
leagueName,
raceSOF
}: RaceResultsHeaderProps) {
return (
<div className="relative overflow-hidden rounded-2xl bg-gray-500/10 border border-gray-500/30 p-6 sm:p-8">
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full blur-3xl" />
<div className="relative z-10">
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-performance-green/10 border border-performance-green/30">
<Trophy className="w-4 h-4 text-performance-green" />
<span className="text-sm font-semibold text-performance-green">
Final Results
</span>
</div>
{raceSOF && (
<span className="flex items-center gap-1.5 text-warning-amber text-sm">
<Zap className="w-4 h-4" />
SOF {raceSOF}
</span>
)}
</div>
<h1 className="text-2xl sm:text-3xl font-bold text-white mb-2">
{raceTrack || DEFAULT_RACE_TRACK} Results
</h1>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-gray-400">
{raceScheduledAt && (
<span className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
{new Date(raceScheduledAt).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
})}
</span>
)}
{totalDrivers !== undefined && totalDrivers !== null && (
<span className="flex items-center gap-2">
<Users className="w-4 h-4" />
{totalDrivers} drivers classified
</span>
)}
{leagueName && <span className="text-primary-blue">{leagueName}</span>}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { CalendarDays, Clock, Zap, Trophy } from 'lucide-react';
interface RaceStatsProps {
stats: {
total: number;
scheduled: number;
running: number;
completed: number;
};
className?: string;
}
export function RaceStats({ stats, className }: RaceStatsProps) {
return (
<div className={`relative z-10 grid grid-cols-2 md:grid-cols-4 gap-4 mt-6 ${className || ''}`}>
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1">
<CalendarDays className="w-4 h-4" />
<span>Total</span>
</div>
<p className="text-2xl font-bold text-white">{stats.total}</p>
</div>
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
<div className="flex items-center gap-2 text-primary-blue text-sm mb-1">
<Clock className="w-4 h-4" />
<span>Scheduled</span>
</div>
<p className="text-2xl font-bold text-white">{stats.scheduled}</p>
</div>
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
<div className="flex items-center gap-2 text-performance-green text-sm mb-1">
<Zap className="w-4 h-4" />
<span>Live Now</span>
</div>
<p className="text-2xl font-bold text-white">{stats.running}</p>
</div>
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1">
<Trophy className="w-4 h-4" />
<span>Completed</span>
</div>
<p className="text-2xl font-bold text-white">{stats.completed}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { CheckCircle, Clock, Gavel } from 'lucide-react';
interface RaceStewardingStatsProps {
pendingCount: number;
resolvedCount: number;
penaltiesCount: number;
}
export default function RaceStewardingStats({ pendingCount, resolvedCount, penaltiesCount }: RaceStewardingStatsProps) {
return (
<div className="grid grid-cols-3 gap-4">
<div className="rounded-lg bg-deep-graphite/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-warning-amber mb-1">
<Clock className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Pending</span>
</div>
<div className="text-2xl font-bold text-white">{pendingCount}</div>
</div>
<div className="rounded-lg bg-deep-graphite/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-performance-green mb-1">
<CheckCircle className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Resolved</span>
</div>
<div className="text-2xl font-bold text-white">{resolvedCount}</div>
</div>
<div className="rounded-lg bg-deep-graphite/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-red-400 mb-1">
<Gavel className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Penalties</span>
</div>
<div className="text-2xl font-bold text-white">{penaltiesCount}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { ChevronRight } from 'lucide-react';
import { formatTime, formatDate } from '@/lib/utilities/time';
interface SidebarRaceItemProps {
race: {
id: string;
track: string;
scheduledAt: string;
};
onClick?: () => void;
className?: string;
}
export function SidebarRaceItem({ race, onClick, className }: SidebarRaceItemProps) {
const scheduledAtDate = new Date(race.scheduledAt);
return (
<div
onClick={onClick}
className={`flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors ${className || ''}`}
>
<div className="flex-shrink-0 w-10 h-10 bg-primary-blue/10 rounded-lg flex items-center justify-center">
<span className="text-sm font-bold text-primary-blue">
{scheduledAtDate.getDate()}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white truncate">{race.track}</p>
<p className="text-xs text-gray-500">{formatTime(scheduledAtDate)}</p>
</div>
<ChevronRight className="w-4 h-4 text-gray-500" />
</div>
);
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
import Button from '@/components/ui/Button';
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
className?: string;
}
export const EmptyState = ({
icon: Icon,
title,
description,
action,
className = ''
}: EmptyStateProps) => (
<div className={`text-center py-12 ${className}`}>
<div className="max-w-md mx-auto">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-2xl bg-iron-gray/60 border border-charcoal-outline/50 mb-6">
<Icon className="w-8 h-8 text-gray-500" />
</div>
<h3 className="text-xl font-semibold text-white mb-3">{title}</h3>
{description && (
<p className="text-gray-400 mb-8">{description}</p>
)}
{action && (
<Button variant="primary" onClick={action.onClick} className="mx-auto">
{action.label}
</Button>
)}
</div>
</div>
);

View File

@@ -0,0 +1,103 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
import Heading from '@/components/ui/Heading';
import Button from '@/components/ui/Button';
interface HeroSectionProps {
title: string;
description?: string;
icon?: LucideIcon;
backgroundPattern?: React.ReactNode;
stats?: Array<{
icon: LucideIcon;
value: string | number;
label: string;
}>;
actions?: Array<{
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary';
}>;
children?: React.ReactNode;
className?: string;
}
export const HeroSection = ({
title,
description,
icon: Icon,
backgroundPattern,
stats,
actions,
children,
className = ''
}: HeroSectionProps) => (
<section className={`relative overflow-hidden ${className}`}>
{/* Background Pattern */}
{backgroundPattern && (
<div className="absolute inset-0">
{backgroundPattern}
</div>
)}
<div className="relative max-w-7xl mx-auto px-6 py-10">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8">
{/* Main Content */}
<div className="max-w-2xl">
{Icon && (
<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>
)}
{!Icon && (
<Heading level={1} className="text-3xl lg:text-4xl mb-4">
{title}
</Heading>
)}
{description && (
<p className="text-gray-400 text-lg leading-relaxed">
{description}
</p>
)}
{/* Stats */}
{stats && stats.length > 0 && (
<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" />
<span className="text-sm text-gray-400">
<span className="text-white font-semibold">{stat.value}</span> {stat.label}
</span>
</div>
))}
</div>
)}
</div>
{/* Actions or Custom Content */}
{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>
)}
{children}
</div>
</div>
</section>
);

View File

@@ -0,0 +1,15 @@
import React from 'react';
interface LoadingStateProps {
message?: string;
className?: string;
}
export const LoadingState = ({ message = 'Loading...', className = '' }: LoadingStateProps) => (
<div className={`flex items-center justify-center min-h-[200px] ${className}`}>
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
<p className="text-gray-400">{message}</p>
</div>
</div>
);

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
interface StatusBadgeProps {
status: string;
config?: {
icon: LucideIcon;
color: string;
bg: string;
border: string;
label: string;
};
className?: string;
}
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',
label: 'Scheduled',
},
running: {
icon: () => null,
color: 'text-performance-green',
bg: 'bg-performance-green/10',
border: 'border-performance-green/30',
label: 'LIVE',
},
completed: {
icon: () => null,
color: 'text-gray-400',
bg: 'bg-gray-500/10',
border: 'border-gray-500/30',
label: 'Completed',
},
cancelled: {
icon: () => null,
color: 'text-warning-amber',
bg: 'bg-warning-amber/10',
border: 'border-warning-amber/30',
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;
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>
);
};

View File

@@ -0,0 +1,46 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { useServices } from '@/lib/services/ServiceProvider';
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) {
const { mediaService } = useServices();
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={mediaService.getDriverAvatar(friend.id)}
alt={friend.name}
width={32}
height={32}
className="w-full h-full object-cover"
/>
</div>
<span className="text-sm text-white">{friend.name}</span>
<span className="text-lg">{getCountryFlag(friend.country)}</span>
</Link>
);
}

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { MessageCircle, Twitter, Youtube, Twitch } from 'lucide-react';
import type { DriverProfileSocialHandleViewModel } from '@/lib/view-models/DriverProfileViewModel';
interface SocialHandlesProps {
socialHandles: DriverProfileSocialHandleViewModel[];
}
function getSocialIcon(platform: DriverProfileSocialHandleViewModel['platform']) {
switch (platform) {
case 'twitter':
return Twitter;
case 'youtube':
return Youtube;
case 'twitch':
return Twitch;
case 'discord':
return MessageCircle;
}
}
function getSocialColor(platform: DriverProfileSocialHandleViewModel['platform']) {
switch (platform) {
case 'twitter':
return 'hover:text-sky-400 hover:bg-sky-400/10';
case 'youtube':
return 'hover:text-red-500 hover:bg-red-500/10';
case 'twitch':
return 'hover:text-purple-400 hover:bg-purple-400/10';
case 'discord':
return 'hover:text-indigo-400 hover:bg-indigo-400/10';
}
}
export default function SocialHandles({ socialHandles }: SocialHandlesProps) {
if (socialHandles.length === 0) return null;
return (
<div className="mt-6 pt-6 border-t border-charcoal-outline/50">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-gray-500 mr-2">Connect:</span>
{socialHandles.map((social) => {
const Icon = getSocialIcon(social.platform);
return (
<a
key={social.platform}
href={social.url}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg bg-iron-gray/50 border border-charcoal-outline text-gray-400 transition-all ${getSocialColor(social.platform)}`}
>
<Icon className="w-4 h-4" />
<span className="text-sm">{social.handle}</span>
<svg className="w-3 h-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
interface ActivityItemProps {
activity: {
id: string;
message: string;
time: string;
typeColor: string;
formattedImpressions?: string | null;
};
}
export default function ActivityItem({ activity }: ActivityItemProps) {
return (
<div className="flex items-start gap-3 py-3 border-b border-charcoal-outline/50 last:border-b-0">
<div className={`w-2 h-2 rounded-full mt-2 ${activity.typeColor}`} />
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{activity.message}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-500">{activity.time}</span>
{activity.formattedImpressions && (
<>
<span className="text-xs text-gray-600"></span>
<span className="text-xs text-gray-400">{activity.formattedImpressions} views</span>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { motion, useReducedMotion } from 'framer-motion';
import { ArrowUpRight, ArrowDownRight } from 'lucide-react';
import Card from '@/components/ui/Card';
interface MetricCardProps {
title: string;
value: number | string;
change?: number;
icon: React.ElementType;
suffix?: string;
prefix?: string;
delay?: number;
}
export default function MetricCard({
title,
value,
change,
icon: Icon,
suffix = '',
prefix = '',
delay = 0,
}: MetricCardProps) {
const shouldReduceMotion = useReducedMotion();
const isPositive = change && change > 0;
const isNegative = change && change < 0;
return (
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay }}
>
<Card className="p-5 h-full">
<div className="flex items-start justify-between mb-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary-blue/10">
<Icon className="w-5 h-5 text-primary-blue" />
</div>
{change !== undefined && (
<div className={`flex items-center gap-1 text-sm font-medium ${
isPositive ? 'text-performance-green' : isNegative ? 'text-racing-red' : 'text-gray-400'
}`}>
{isPositive ? <ArrowUpRight className="w-4 h-4" /> : isNegative ? <ArrowDownRight className="w-4 h-4" /> : null}
{Math.abs(change)}%
</div>
)}
</div>
<div className="text-2xl font-bold text-white mb-1">
{prefix}{typeof value === 'number' ? value.toLocaleString() : value}{suffix}
</div>
<div className="text-sm text-gray-400">{title}</div>
</Card>
</motion.div>
);
}

View File

@@ -0,0 +1,41 @@
import { Trophy, Users, Car, Flag, Megaphone } from 'lucide-react';
import Button from '@/components/ui/Button';
interface RenewalAlertProps {
renewal: {
id: string;
type: 'league' | 'team' | 'driver' | 'race' | 'platform';
name: string;
formattedRenewDate: string;
formattedPrice: string;
};
}
export default function RenewalAlert({ renewal }: RenewalAlertProps) {
const typeIcons = {
league: Trophy,
team: Users,
driver: Car,
race: Flag,
platform: Megaphone,
};
const Icon = typeIcons[renewal.type] || Trophy;
return (
<div className="flex items-center justify-between p-3 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
<div className="flex items-center gap-3">
<Icon className="w-4 h-4 text-warning-amber" />
<div>
<p className="text-sm text-white">{renewal.name}</p>
<p className="text-xs text-gray-400">Renews {renewal.formattedRenewDate}</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-semibold text-white">{renewal.formattedPrice}</p>
<Button variant="secondary" className="text-xs mt-1 py-1 px-2 min-h-0">
Renew
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import Link from 'next/link';
import Card from '@/components/ui/Card';
interface SponsorshipCategoryCardProps {
icon: React.ElementType;
title: string;
count: number;
impressions: number;
color: string;
href: string;
}
export default function SponsorshipCategoryCard({
icon: Icon,
title,
count,
impressions,
color,
href
}: SponsorshipCategoryCardProps) {
return (
<Link href={href}>
<Card className="p-4 hover:border-primary-blue/50 transition-all duration-300 cursor-pointer group">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg bg-iron-gray flex items-center justify-center group-hover:bg-primary-blue/10 transition-colors`}>
<Icon className={`w-5 h-5 ${color}`} />
</div>
<div>
<p className="text-white font-medium">{title}</p>
<p className="text-sm text-gray-500">{count} active</p>
</div>
</div>
<div className="text-right">
<p className="text-white font-semibold">{impressions.toLocaleString()}</p>
<p className="text-xs text-gray-500">impressions</p>
</div>
</div>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,110 @@
import { UserPlus, Users, Trophy } from 'lucide-react';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
const SKILL_LEVELS: {
id: string;
label: string;
icon: React.ElementType;
color: string;
bgColor: string;
borderColor: string;
}[] = [
{
id: 'pro',
label: 'Pro',
icon: () => null, // We'll import Crown if needed
color: 'text-yellow-400',
bgColor: 'bg-yellow-400/10',
borderColor: 'border-yellow-400/30',
},
{
id: 'advanced',
label: 'Advanced',
icon: () => null,
color: 'text-purple-400',
bgColor: 'bg-purple-400/10',
borderColor: 'border-purple-400/30',
},
{
id: 'intermediate',
label: 'Intermediate',
icon: () => null,
color: 'text-primary-blue',
bgColor: 'bg-primary-blue/10',
borderColor: 'border-primary-blue/30',
},
{
id: 'beginner',
label: 'Beginner',
icon: () => null,
color: 'text-green-400',
bgColor: 'bg-green-400/10',
borderColor: 'border-green-400/30',
},
];
interface FeaturedRecruitingProps {
teams: TeamSummaryViewModel[];
onTeamClick: (id: string) => void;
}
export default function FeaturedRecruiting({ teams, onTeamClick }: FeaturedRecruitingProps) {
const recruitingTeams = teams.filter((t) => t.isRecruiting).slice(0, 4);
if (recruitingTeams.length === 0) return null;
return (
<div className="mb-10">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-performance-green/10 border border-performance-green/20">
<UserPlus className="w-5 h-5 text-performance-green" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Looking for Drivers</h2>
<p className="text-xs text-gray-500">Teams actively recruiting new members</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{recruitingTeams.map((team) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
return (
<button
key={team.id}
type="button"
onClick={() => onTeamClick(team.id)}
className="p-4 rounded-xl bg-iron-gray/60 border border-charcoal-outline hover:border-performance-green/40 transition-all duration-200 text-left group"
>
<div className="flex items-start justify-between mb-3">
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${levelConfig?.bgColor} border ${levelConfig?.borderColor}`}>
{/* LevelIcon would be here */}
</div>
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] bg-performance-green/10 text-performance-green border border-performance-green/20">
<div className="w-1.5 h-1.5 rounded-full bg-performance-green animate-pulse" />
Recruiting
</span>
</div>
<h3 className="text-white font-semibold mb-1 group-hover:text-performance-green transition-colors line-clamp-1">
{team.name}
</h3>
<p className="text-xs text-gray-500 line-clamp-2 mb-3">{team.description}</p>
<div className="flex items-center gap-3 text-xs text-gray-400">
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{team.memberCount}
</span>
<span className="flex items-center gap-1">
<Trophy className="w-3 h-3" />
{team.totalWins} wins
</span>
</div>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { useState } from 'react';
import { ChevronRight, Users, Trophy, UserPlus } from 'lucide-react';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import TeamCard from './TeamCard';
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
interface SkillLevelConfig {
id: SkillLevel;
label: string;
icon: React.ElementType;
color: string;
bgColor: string;
borderColor: string;
description: string;
}
interface SkillLevelSectionProps {
level: SkillLevelConfig;
teams: TeamSummaryViewModel[];
onTeamClick: (id: string) => void;
defaultExpanded?: boolean;
}
export default function SkillLevelSection({
level,
teams,
onTeamClick,
defaultExpanded = false
}: SkillLevelSectionProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const recruitingTeams = teams.filter((t) => t.isRecruiting);
const displayedTeams = isExpanded ? teams : teams.slice(0, 3);
const Icon = level.icon;
if (teams.length === 0) return null;
return (
<div className="mb-8">
{/* Section Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`flex h-11 w-11 items-center justify-center rounded-xl ${level.bgColor} border ${level.borderColor}`}>
<Icon className={`w-5 h-5 ${level.color}`} />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white">{level.label}</h2>
<span className="px-2 py-0.5 rounded-full text-xs bg-charcoal-outline/50 text-gray-400">
{teams.length} {teams.length === 1 ? 'team' : 'teams'}
</span>
{recruitingTeams.length > 0 && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-performance-green/10 text-performance-green border border-performance-green/20">
<UserPlus className="w-3 h-3" />
{recruitingTeams.length} recruiting
</span>
)}
</div>
<p className="text-sm text-gray-500">{level.description}</p>
</div>
</div>
{teams.length > 3 && (
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm text-gray-400 hover:text-white hover:bg-iron-gray/50 transition-all"
>
{isExpanded ? 'Show less' : `View all ${teams.length}`}
<ChevronRight className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</button>
)}
</div>
{/* Teams Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{displayedTeams.map((team) => (
<TeamCard
key={team.id}
id={team.id}
name={team.name}
description={team.description ?? ''}
memberCount={team.memberCount}
rating={team.rating}
totalWins={team.totalWins}
totalRaces={team.totalRaces}
performanceLevel={team.performanceLevel}
isRecruiting={team.isRecruiting}
specialization={team.specialization}
region={team.region ?? ''}
languages={team.languages}
onClick={() => onTeamClick(team.id)}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
interface StatItemProps {
label: string;
value: string;
color: string;
}
export default function StatItem({ label, value, color }: StatItemProps) {
return (
<div className="flex items-center justify-between">
<span className="text-gray-400 text-sm">{label}</span>
<span className={`font-semibold ${color}`}>{value}</span>
</div>
);
}

View File

@@ -0,0 +1,175 @@
import { useRouter } from 'next/navigation';
import { Award, ChevronRight, Crown, Trophy, Users } from 'lucide-react';
import Button from '@/components/ui/Button';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
const SKILL_LEVELS: {
id: string;
label: string;
icon: React.ElementType;
color: string;
bgColor: string;
borderColor: string;
}[] = [
{
id: 'pro',
label: 'Pro',
icon: () => null,
color: 'text-yellow-400',
bgColor: 'bg-yellow-400/10',
borderColor: 'border-yellow-400/30',
},
{
id: 'advanced',
label: 'Advanced',
icon: () => null,
color: 'text-purple-400',
bgColor: 'bg-purple-400/10',
borderColor: 'border-purple-400/30',
},
{
id: 'intermediate',
label: 'Intermediate',
icon: () => null,
color: 'text-primary-blue',
bgColor: 'bg-primary-blue/10',
borderColor: 'border-primary-blue/30',
},
{
id: 'beginner',
label: 'Beginner',
icon: () => null,
color: 'text-green-400',
bgColor: 'bg-green-400/10',
borderColor: 'border-green-400/30',
},
];
interface TeamLeaderboardPreviewProps {
topTeams: TeamSummaryViewModel[];
onTeamClick: (id: string) => void;
}
export default function TeamLeaderboardPreview({
topTeams,
onTeamClick
}: TeamLeaderboardPreviewProps) {
const router = useRouter();
const getMedalColor = (position: number) => {
switch (position) {
case 0:
return 'text-yellow-400';
case 1:
return 'text-gray-300';
case 2:
return 'text-amber-600';
default:
return 'text-gray-500';
}
};
const getMedalBg = (position: number) => {
switch (position) {
case 0:
return 'bg-yellow-400/10 border-yellow-400/30';
case 1:
return 'bg-gray-300/10 border-gray-300/30';
case 2:
return 'bg-amber-600/10 border-amber-600/30';
default:
return 'bg-iron-gray/50 border-charcoal-outline';
}
};
if (topTeams.length === 0) return null;
return (
<div className="mb-12">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-yellow-400/20 to-amber-600/10 border border-yellow-400/30">
<Award className="w-5 h-5 text-yellow-400" />
</div>
<div>
<h2 className="text-xl font-bold text-white">Top Teams</h2>
<p className="text-sm text-gray-500">Highest rated racing teams</p>
</div>
</div>
<Button
variant="secondary"
onClick={() => router.push('/teams/leaderboard')}
className="flex items-center gap-2 text-sm"
>
View Full Leaderboard
<ChevronRight className="w-4 h-4" />
</Button>
</div>
{/* Compact Leaderboard */}
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
<div className="divide-y divide-charcoal-outline/50">
{topTeams.map((team, index) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
return (
<button
key={team.id}
type="button"
onClick={() => onTeamClick(team.id)}
className="flex items-center gap-4 px-4 py-3 w-full text-left hover:bg-iron-gray/30 transition-colors group"
>
{/* Position */}
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold border ${getMedalBg(index)} ${getMedalColor(index)}`}
>
{index < 3 ? (
<Crown className="w-3.5 h-3.5" />
) : (
index + 1
)}
</div>
{/* Team Info */}
<div className={`flex h-9 w-9 items-center justify-center rounded-lg ${levelConfig?.bgColor} border ${levelConfig?.borderColor}`}>
{/* LevelIcon */}
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate group-hover:text-purple-400 transition-colors">
{team.name}
</p>
<div className="flex items-center gap-3 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{team.memberCount}
</span>
<span className="flex items-center gap-1">
<Trophy className="w-3 h-3" />
{team.totalWins} wins
</span>
{team.isRecruiting && (
<span className="flex items-center gap-1 text-performance-green">
<div className="w-1.5 h-1.5 rounded-full bg-performance-green animate-pulse" />
Recruiting
</span>
)}
</div>
</div>
{/* Rating */}
<div className="text-right">
<p className="text-purple-400 font-mono font-semibold">
{(team as any).rating?.toLocaleString() || '—'}
</p>
<p className="text-xs text-gray-500">Rating</p>
</div>
</button>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
import Link from 'next/link';
import { Users, ChevronRight } from 'lucide-react';
interface TeamMembership {
teamId: string;
teamName: string;
teamTag?: string;
role: string;
joinedAt: string;
}
interface TeamMembershipCardProps {
membership: TeamMembership;
}
export default function TeamMembershipCard({ membership }: TeamMembershipCardProps) {
return (
<Link
href={`/teams/${membership.teamId}`}
className="flex items-center gap-4 p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline hover:border-purple-400/30 hover:bg-iron-gray/50 transition-all group"
>
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-600/20 border border-purple-600/30">
<Users className="w-6 h-6 text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-semibold truncate group-hover:text-purple-400 transition-colors">
{membership.teamName}
</p>
<div className="flex items-center gap-2 text-xs text-gray-400">
<span className="px-2 py-0.5 rounded-full bg-purple-600/20 text-purple-400 capitalize">
{membership.role}
</span>
<span>
Since {new Date(membership.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</span>
</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-500 group-hover:text-purple-400 transition-colors" />
</Link>
);
}

View File

@@ -0,0 +1,175 @@
import { Trophy, Crown, Users } from 'lucide-react';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
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',
},
];
interface TopThreePodiumProps {
teams: TeamSummaryViewModel[];
onClick: (id: string) => void;
}
export default function TopThreePodium({ teams, onClick }: TopThreePodiumProps) {
const top3 = teams.slice(0, 3) as [TeamSummaryViewModel, TeamSummaryViewModel, TeamSummaryViewModel];
if (teams.length < 3) return null;
// Display order: 2nd, 1st, 3rd
const podiumOrder: [TeamSummaryViewModel, TeamSummaryViewModel, TeamSummaryViewModel] = [
top3[1],
top3[0],
top3[2],
];
const podiumHeights = ['h-28', 'h-36', 'h-20'];
const podiumPositions = [2, 1, 3];
const getPositionColor = (position: number) => {
switch (position) {
case 1:
return 'text-yellow-400';
case 2:
return 'text-gray-300';
case 3:
return 'text-amber-600';
default:
return 'text-gray-500';
}
};
const getGradient = (position: number) => {
switch (position) {
case 1:
return 'from-yellow-400/30 via-yellow-500/20 to-yellow-600/10';
case 2:
return 'from-gray-300/30 via-gray-400/20 to-gray-500/10';
case 3:
return 'from-amber-500/30 via-amber-600/20 to-amber-700/10';
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 (
<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>
<div className="flex items-end justify-center gap-4 md: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={() => onTeamClick(team.id)}
className="flex flex-col items-center group"
>
{/* 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`}
>
{/* 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>
)}
{/* Team icon */}
<div
className={`flex h-16 w-16 md:h-20 md:w-20 items-center justify-center rounded-xl ${levelConfig?.bgColor} border ${levelConfig?.borderColor} mb-3`}
>
{/* LevelIcon */}
</div>
{/* 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>
{/* Rating */}
<p className={`text-lg md:text-xl font-mono font-bold ${getPositionColor(position)} text-center`}>
{(team as any).rating?.toLocaleString() || '—'}
</p>
{/* 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>
{/* 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`}
>
<span className={`text-2xl md:text-3xl font-bold ${getPositionColor(position)}`}>
{position}
</span>
</div>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import {
Handshake,
MessageCircle,
Calendar,
Trophy,
} from 'lucide-react';
export default function WhyJoinTeamSection() {
const benefits = [
{
icon: Handshake,
title: 'Shared Strategy',
description: 'Develop setups together, share telemetry, and coordinate pit strategies for endurance races.',
},
{
icon: MessageCircle,
title: 'Team Communication',
description: 'Discord integration, voice chat during races, and dedicated team channels.',
},
{
icon: Calendar,
title: 'Coordinated Schedule',
description: 'Team calendars, practice sessions, and organized race attendance.',
},
{
icon: Trophy,
title: 'Team Championships',
description: 'Compete in team-based leagues and build your collective reputation.',
},
];
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>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{benefits.map((benefit) => (
<div
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"
>
<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>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import React from 'react';
interface TabContentProps {
activeTab: string;
children: React.ReactNode;
className?: string;
}
export default function TabContent({ activeTab, children, className = '' }: TabContentProps) {
return (
<div className={className}>
{children}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
interface Tab {
id: string;
label: string;
icon?: React.ComponentType<{ className?: string }>;
}
interface TabNavigationProps {
tabs: Tab[];
activeTab: string;
onTabChange: (tabId: string) => void;
className?: string;
}
export default function TabNavigation({ tabs, activeTab, onTabChange, className = '' }: TabNavigationProps) {
return (
<div className={`flex items-center gap-1 p-1.5 rounded-xl bg-iron-gray/50 border border-charcoal-outline w-fit relative z-10 ${className}`}>
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
type="button"
onClick={() => onTabChange(tab.id)}
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all cursor-pointer select-none ${
activeTab === tab.id
? 'bg-primary-blue text-white shadow-lg shadow-primary-blue/25'
: 'text-gray-400 hover:text-white hover:bg-iron-gray/80'
}`}
>
{Icon && <Icon className="w-4 h-4" />}
{tab.label}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,8 @@
export 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 '🏁';
}

View File

@@ -0,0 +1,32 @@
import { Clock, PlayCircle, CheckCircle2, XCircle } from 'lucide-react';
export const raceStatusConfig = {
scheduled: {
icon: Clock,
color: 'text-primary-blue',
bg: 'bg-primary-blue/10',
border: 'border-primary-blue/30',
label: 'Scheduled',
},
running: {
icon: PlayCircle,
color: 'text-performance-green',
bg: 'bg-performance-green/10',
border: 'border-performance-green/30',
label: 'LIVE',
},
completed: {
icon: CheckCircle2,
color: 'text-gray-400',
bg: 'bg-gray-500/10',
border: 'border-gray-500/30',
label: 'Completed',
},
cancelled: {
icon: XCircle,
color: 'text-warning-amber',
bg: 'bg-warning-amber/10',
border: 'border-warning-amber/30',
label: 'Cancelled',
},
};

View File

@@ -0,0 +1,81 @@
export function timeUntil(date: Date): string {
const now = new Date();
const diffMs = date.getTime() - now.getTime();
if (diffMs < 0) return 'Started';
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) {
return `${diffDays}d ${diffHours % 24}h`;
}
if (diffHours > 0) {
const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
return `${diffHours}h ${diffMinutes}m`;
}
const diffMinutes = Math.floor(diffMs / (1000 * 60));
return `${diffMinutes}m`;
}
export function timeAgo(timestamp: Date | string): string {
const time = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
const diffMs = Date.now() - time.getTime();
const diffMinutes = Math.floor(diffMs / 60000);
if (diffMinutes < 1) return 'Just now';
if (diffMinutes < 60) return `${diffMinutes}m ago`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
export function getGreeting(): string {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning';
if (hour < 18) return 'Good afternoon';
return 'Good evening';
}
export function formatTime(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
}
export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
}
export function formatFullDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
});
}
export function getRelativeTime(date?: Date | string): string {
if (!date) return '';
const now = new Date();
const targetDate = typeof date === 'string' ? new Date(date) : date;
const diffMs = targetDate.getTime() - now.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMs < 0) return 'Past';
if (diffHours < 1) return 'Starting soon';
if (diffHours < 24) return `In ${diffHours}h`;
if (diffDays === 1) return 'Tomorrow';
if (diffDays < 7) return `In ${diffDays} days`;
return formatDate(targetDate);
}