extract components from website
This commit is contained in:
@@ -27,6 +27,7 @@ import Input from '@/components/ui/Input';
|
|||||||
import Heading from '@/components/ui/Heading';
|
import Heading from '@/components/ui/Heading';
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
import AuthWorkflowMockup from '@/components/auth/AuthWorkflowMockup';
|
import AuthWorkflowMockup from '@/components/auth/AuthWorkflowMockup';
|
||||||
|
import UserRolesPreview from '@/components/auth/UserRolesPreview';
|
||||||
|
|
||||||
interface FormErrors {
|
interface FormErrors {
|
||||||
email?: string;
|
email?: string;
|
||||||
@@ -34,26 +35,6 @@ interface FormErrors {
|
|||||||
submit?: string;
|
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() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -167,25 +148,7 @@ export default function LoginPage() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Role Cards */}
|
{/* Role Cards */}
|
||||||
<div className="space-y-3 mb-8">
|
<UserRolesPreview variant="full" />
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* Workflow Mockup */}
|
{/* Workflow Mockup */}
|
||||||
<AuthWorkflowMockup />
|
<AuthWorkflowMockup />
|
||||||
@@ -365,19 +328,7 @@ export default function LoginPage() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Mobile Role Info */}
|
{/* Mobile Role Info */}
|
||||||
<div className="mt-8 lg:hidden">
|
<UserRolesPreview variant="compact" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -23,58 +23,15 @@ import {
|
|||||||
|
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
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';
|
import { useDashboardOverview } from '@/hooks/useDashboardService';
|
||||||
|
import { getCountryFlag } from '@/lib/utilities/country';
|
||||||
|
import { getGreeting, timeUntil, timeAgo } from '@/lib/utilities/time';
|
||||||
// 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 { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
|
import { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
|
||||||
|
|
||||||
@@ -176,50 +133,10 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{/* Quick Stats Row */}
|
{/* Quick Stats Row */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
|
<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">
|
<StatCard icon={Trophy} value={wins} label="Wins" color="bg-performance-green/20 text-performance-green" />
|
||||||
<div className="flex items-center gap-3">
|
<StatCard icon={Medal} value={podiums} label="Podiums" color="bg-warning-amber/20 text-warning-amber" />
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/20">
|
<StatCard icon={Target} value={`${consistency}%`} label="Consistency" color="bg-primary-blue/20 text-primary-blue" />
|
||||||
<Trophy className="w-5 h-5 text-performance-green" />
|
<StatCard icon={Users} value={activeLeaguesCount} label="Active Leagues" color="bg-purple-500/20 text-purple-400" />
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -300,38 +217,14 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{leagueStandingsSummaries.map(({ leagueId, leagueName, position, points, totalDrivers }) => (
|
{leagueStandingsSummaries.map(({ leagueId, leagueName, position, points, totalDrivers }) => (
|
||||||
<Link
|
<LeagueStandingItem
|
||||||
key={leagueId}
|
key={leagueId}
|
||||||
href={`/leagues/${leagueId}/standings`}
|
leagueId={leagueId}
|
||||||
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"
|
leagueName={leagueName}
|
||||||
>
|
position={position}
|
||||||
<div className={`flex h-12 w-12 items-center justify-center rounded-xl font-bold text-xl ${
|
points={points}
|
||||||
position === 1 ? 'bg-yellow-400/20 text-yellow-400' :
|
totalDrivers={totalDrivers}
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -376,30 +269,16 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
{upcomingRaces.length > 0 ? (
|
{upcomingRaces.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{upcomingRaces.slice(0, 5).map((race) => {
|
{upcomingRaces.slice(0, 5).map((race) => (
|
||||||
const isMyRace = race.isMyLeague;
|
<UpcomingRaceItem
|
||||||
return (
|
key={race.id}
|
||||||
<Link
|
id={race.id}
|
||||||
key={race.id}
|
track={race.track}
|
||||||
href={`/races/${race.id}`}
|
car={race.car}
|
||||||
className="block p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/30 transition-colors"
|
scheduledAt={race.scheduledAt}
|
||||||
>
|
isMyLeague={race.isMyLeague}
|
||||||
<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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-500 text-sm text-center py-4">No upcoming races</p>
|
<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 ? (
|
{friends.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{friends.slice(0, 6).map((friend) => (
|
{friends.slice(0, 6).map((friend) => (
|
||||||
<Link
|
<FriendItem
|
||||||
key={friend.id}
|
key={friend.id}
|
||||||
href={`/drivers/${friend.id}`}
|
id={friend.id}
|
||||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite transition-colors"
|
name={friend.name}
|
||||||
>
|
avatarUrl={friend.avatarUrl}
|
||||||
<div className="w-9 h-9 rounded-full overflow-hidden bg-gradient-to-br from-primary-blue to-purple-600">
|
country={friend.country}
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
{friends.length > 6 && (
|
{friends.length > 6 && (
|
||||||
<Link
|
<Link
|
||||||
@@ -465,40 +332,3 @@ export default function DashboardPage() {
|
|||||||
</main>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
|
import PointsTable from '@/components/leagues/PointsTable';
|
||||||
import { useServices } from '@/lib/services/ServiceProvider';
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
|
import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
|
||||||
|
|
||||||
@@ -124,49 +125,7 @@ export default function LeagueRulebookPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Points Table */}
|
{/* Points Table */}
|
||||||
<Card>
|
<PointsTable points={positionPoints} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Bonus Points */}
|
{/* Bonus Points */}
|
||||||
{primaryChampionship?.bonusSummary && primaryChampionship.bonusSummary.length > 0 && (
|
{primaryChampionship?.bonusSummary && primaryChampionship.bonusSummary.length > 0 && (
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ReadonlyLeagueInfo } from '@/components/leagues/ReadonlyLeagueInfo';
|
import { ReadonlyLeagueInfo } from '@/components/leagues/ReadonlyLeagueInfo';
|
||||||
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
|
import LeagueOwnershipTransfer from '@/components/leagues/LeagueOwnershipTransfer';
|
||||||
import Button from '@/components/ui/Button';
|
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||||
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||||
@@ -22,9 +21,6 @@ export default function LeagueSettingsPage() {
|
|||||||
const [settings, setSettings] = useState<LeagueSettingsViewModel | null>(null);
|
const [settings, setSettings] = useState<LeagueSettingsViewModel | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
const [showTransferDialog, setShowTransferDialog] = useState(false);
|
|
||||||
const [selectedNewOwner, setSelectedNewOwner] = useState<string>('');
|
|
||||||
const [transferring, setTransferring] = useState(false);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -58,20 +54,12 @@ export default function LeagueSettingsPage() {
|
|||||||
|
|
||||||
const ownerSummary = settings?.owner || null;
|
const ownerSummary = settings?.owner || null;
|
||||||
|
|
||||||
const handleTransferOwnership = async () => {
|
const handleTransferOwnership = async (newOwnerId: string) => {
|
||||||
if (!selectedNewOwner || !settings) return;
|
|
||||||
|
|
||||||
setTransferring(true);
|
|
||||||
try {
|
try {
|
||||||
await leagueSettingsService.transferOwnership(leagueId, currentDriverId, selectedNewOwner);
|
await leagueSettingsService.transferOwnership(leagueId, currentDriverId, newOwnerId);
|
||||||
|
|
||||||
setShowTransferDialog(false);
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to transfer ownership:', err);
|
throw err; // Let the component handle the error
|
||||||
alert(err instanceof Error ? err.message : 'Failed to transfer ownership');
|
|
||||||
} finally {
|
|
||||||
setTransferring(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,76 +116,11 @@ export default function LeagueSettingsPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ReadonlyLeagueInfo league={settings.league} configForm={settings.config} />
|
<ReadonlyLeagueInfo league={settings.league} configForm={settings.config} />
|
||||||
|
|
||||||
{/* League Owner - Compact */}
|
<LeagueOwnershipTransfer
|
||||||
<div className="rounded-xl border border-charcoal-outline bg-gradient-to-br from-iron-gray/40 to-iron-gray/20 p-5">
|
settings={settings}
|
||||||
<h3 className="text-sm font-semibold text-gray-400 mb-3">League Owner</h3>
|
currentDriverId={currentDriverId}
|
||||||
{ownerSummary ? (
|
onTransferOwnership={handleTransferOwnership}
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import StandingsTable from '@/components/leagues/StandingsTable';
|
import StandingsTable from '@/components/leagues/StandingsTable';
|
||||||
|
import LeagueChampionshipStats from '@/components/leagues/LeagueChampionshipStats';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||||
import type { LeagueMembership, MembershipRole } from '@/lib/types';
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Championship Stats */}
|
{/* Championship Stats */}
|
||||||
{standings.length > 0 && (
|
<LeagueChampionshipStats standings={standings} drivers={drivers} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<h2 className="text-xl font-semibold text-white mb-4">Championship Standings</h2>
|
<h2 className="text-xl font-semibold text-white mb-4">Championship Standings</h2>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import PenaltyFAB from '@/components/leagues/PenaltyFAB';
|
import PenaltyFAB from '@/components/leagues/PenaltyFAB';
|
||||||
import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal';
|
import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal';
|
||||||
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
|
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
|
||||||
|
import StewardingStats from '@/components/leagues/StewardingStats';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||||
@@ -183,29 +184,11 @@ export default function LeagueStewardingPage() {
|
|||||||
|
|
||||||
{/* Stats summary */}
|
{/* Stats summary */}
|
||||||
{!loading && stewardingData && (
|
{!loading && stewardingData && (
|
||||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
<StewardingStats
|
||||||
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
|
totalPending={stewardingData.totalPending}
|
||||||
<div className="flex items-center gap-2 text-warning-amber mb-1">
|
totalResolved={stewardingData.totalResolved}
|
||||||
<Clock className="w-4 h-4" />
|
totalPenalties={stewardingData.totalPenalties}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tab navigation */}
|
{/* Tab navigation */}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import TransactionRow from '@/components/leagues/TransactionRow';
|
||||||
import { useServices } from '@/lib/services/ServiceProvider';
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
|
import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
|
||||||
import {
|
import {
|
||||||
@@ -21,69 +22,6 @@ import {
|
|||||||
Calendar
|
Calendar
|
||||||
} from 'lucide-react';
|
} 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() {
|
export default function LeagueWalletPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||||
import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal';
|
import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal';
|
||||||
import ImportResultsForm from '@/components/races/ImportResultsForm';
|
import ImportResultsForm from '@/components/races/ImportResultsForm';
|
||||||
|
import RaceResultsHeader from '@/components/races/RaceResultsHeader';
|
||||||
import ResultsTable from '@/components/races/ResultsTable';
|
import ResultsTable from '@/components/races/ResultsTable';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
@@ -132,50 +133,13 @@ export default function RaceResultsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative overflow-hidden rounded-2xl bg-gray-500/10 border border-gray-500/30 p-6 sm:p-8">
|
<RaceResultsHeader
|
||||||
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full blur-3xl" />
|
raceTrack={raceData?.race?.track}
|
||||||
|
raceScheduledAt={raceData?.race?.scheduledAt}
|
||||||
<div className="relative z-10">
|
totalDrivers={raceData?.stats.totalDrivers}
|
||||||
<div className="flex items-center gap-3 mb-4">
|
leagueName={raceData?.league?.name}
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-performance-green/10 border border-performance-green/30">
|
raceSOF={raceSOF}
|
||||||
<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>
|
|
||||||
|
|
||||||
{importSuccess && (
|
{importSuccess && (
|
||||||
<div className="p-4 bg-performance-green/10 border border-performance-green/30 rounded-lg text-performance-green">
|
<div className="p-4 bg-performance-green/10 border border-performance-green/30 rounded-lg text-performance-green">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||||
|
import RaceStewardingStats from '@/components/races/RaceStewardingStats';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||||
@@ -154,29 +155,11 @@ export default function RaceStewardingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<RaceStewardingStats
|
||||||
<div className="rounded-lg bg-deep-graphite/50 border border-charcoal-outline p-4">
|
pendingCount={stewardingData?.pendingCount ?? 0}
|
||||||
<div className="flex items-center gap-2 text-warning-amber mb-1">
|
resolvedCount={stewardingData?.resolvedCount ?? 0}
|
||||||
<Clock className="w-4 h-4" />
|
penaltiesCount={stewardingData?.penaltiesCount ?? 0}
|
||||||
<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>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import Card from '@/components/ui/Card';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import StatusBadge from '@/components/ui/StatusBadge';
|
import StatusBadge from '@/components/ui/StatusBadge';
|
||||||
import InfoBanner from '@/components/ui/InfoBanner';
|
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 {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -35,146 +39,9 @@ import { SponsorService } from '@/lib/services/sponsors/SponsorService';
|
|||||||
import { SponsorDashboardViewModel } from '@/lib/view-models/SponsorDashboardViewModel';
|
import { SponsorDashboardViewModel } from '@/lib/view-models/SponsorDashboardViewModel';
|
||||||
import { ServiceFactory } from '@/lib/services/ServiceFactory';
|
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() {
|
export default function SponsorDashboardPage() {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import JoinTeamButton from '@/components/teams/JoinTeamButton';
|
|||||||
import TeamAdmin from '@/components/teams/TeamAdmin';
|
import TeamAdmin from '@/components/teams/TeamAdmin';
|
||||||
import TeamRoster from '@/components/teams/TeamRoster';
|
import TeamRoster from '@/components/teams/TeamRoster';
|
||||||
import TeamStandings from '@/components/teams/TeamStandings';
|
import TeamStandings from '@/components/teams/TeamStandings';
|
||||||
|
import StatItem from '@/components/teams/StatItem';
|
||||||
import { useServices } from '@/lib/services/ServiceProvider';
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
|
import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
|
||||||
import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
|
import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
|
||||||
@@ -319,12 +320,3 @@ export default function TeamDetailPage() {
|
|||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import Heading from '@/components/ui/Heading';
|
import Heading from '@/components/ui/Heading';
|
||||||
|
import TopThreePodium from '@/components/teams/TopThreePodium';
|
||||||
import { useAllTeams } from '@/hooks/useTeamService';
|
import { useAllTeams } from '@/hooks/useTeamService';
|
||||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
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 },
|
{ 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
|
// MAIN PAGE COMPONENT
|
||||||
@@ -455,7 +316,7 @@ export default function TeamLeaderboardPage() {
|
|||||||
|
|
||||||
{/* Podium for Top 3 - only show when viewing by rating without filters */}
|
{/* Podium for Top 3 - only show when viewing by rating without filters */}
|
||||||
{sortBy === 'rating' && filterLevel === 'all' && !searchQuery && filteredAndSortedTeams.length >= 3 && (
|
{sortBy === 'rating' && filterLevel === 'all' && !searchQuery && filteredAndSortedTeams.length >= 3 && (
|
||||||
<TopThreePodium teams={filteredAndSortedTeams} onTeamClick={handleTeamClick} />
|
<TopThreePodium teams={filteredAndSortedTeams} onClick={handleTeamClick} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats Summary */}
|
{/* Stats Summary */}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ import Card from '@/components/ui/Card';
|
|||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import Heading from '@/components/ui/Heading';
|
import Heading from '@/components/ui/Heading';
|
||||||
import CreateTeamForm from '@/components/teams/CreateTeamForm';
|
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 { useAllTeams } from '@/hooks/useTeamService';
|
||||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
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
|
// MAIN PAGE COMPONENT
|
||||||
|
|||||||
63
apps/website/components/achievements/AchievementCard.tsx
Normal file
63
apps/website/components/achievements/AchievementCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
apps/website/components/auth/UserRolesPreview.tsx
Normal file
70
apps/website/components/auth/UserRolesPreview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
apps/website/components/charts/CircularProgress.tsx
Normal file
52
apps/website/components/charts/CircularProgress.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
apps/website/components/charts/FinishDistributionChart.tsx
Normal file
45
apps/website/components/charts/FinishDistributionChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
apps/website/components/charts/HorizontalBarChart.tsx
Normal file
27
apps/website/components/charts/HorizontalBarChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
apps/website/components/dashboard/FeedItemRow.tsx
Normal file
43
apps/website/components/dashboard/FeedItemRow.tsx
Normal 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 };
|
||||||
33
apps/website/components/dashboard/FriendItem.tsx
Normal file
33
apps/website/components/dashboard/FriendItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
apps/website/components/dashboard/LeagueStandingItem.tsx
Normal file
54
apps/website/components/dashboard/LeagueStandingItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/website/components/dashboard/StatCard.tsx
Normal file
25
apps/website/components/dashboard/StatCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
apps/website/components/dashboard/UpcomingRaceItem.tsx
Normal file
39
apps/website/components/dashboard/UpcomingRaceItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
apps/website/components/drivers/HeroSection.tsx
Normal file
84
apps/website/components/drivers/HeroSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
apps/website/components/landing/FeatureItem.tsx
Normal file
23
apps/website/components/landing/FeatureItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
apps/website/components/leagues/EmptyState.tsx
Normal file
53
apps/website/components/leagues/EmptyState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
apps/website/components/leagues/HeroSection.tsx
Normal file
85
apps/website/components/leagues/HeroSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
apps/website/components/leagues/LeagueChampionshipStats.tsx
Normal file
59
apps/website/components/leagues/LeagueChampionshipStats.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
apps/website/components/leagues/LeagueOwnershipTransfer.tsx
Normal file
114
apps/website/components/leagues/LeagueOwnershipTransfer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
apps/website/components/leagues/LeagueSlider.tsx
Normal file
204
apps/website/components/leagues/LeagueSlider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
45
apps/website/components/leagues/NoResultsState.tsx
Normal file
45
apps/website/components/leagues/NoResultsState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
apps/website/components/leagues/PointsTable.tsx
Normal file
55
apps/website/components/leagues/PointsTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
apps/website/components/leagues/SearchAndFilterBar.tsx
Normal file
95
apps/website/components/leagues/SearchAndFilterBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
apps/website/components/leagues/StewardingStats.tsx
Normal file
36
apps/website/components/leagues/StewardingStats.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
apps/website/components/leagues/TransactionRow.tsx
Normal file
91
apps/website/components/leagues/TransactionRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/website/components/profile/LiveryCard.tsx
Normal file
76
apps/website/components/profile/LiveryCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
apps/website/components/races/LiveRaceBanner.tsx
Normal file
51
apps/website/components/races/LiveRaceBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/website/components/races/QuickActions.tsx
Normal file
32
apps/website/components/races/QuickActions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,185 +1,98 @@
|
|||||||
'use client';
|
import Link from 'next/link';
|
||||||
|
import { ChevronRight, Car, Zap, Trophy, ArrowRight } from 'lucide-react';
|
||||||
import { Race } from '@core/racing/domain/entities/Race';
|
import { formatTime, getRelativeTime } from '@/lib/utilities/time';
|
||||||
import { Clock, PlayCircle, CheckCircle2, XCircle, Zap, Car, Trophy } from 'lucide-react';
|
import { raceStatusConfig } from '@/lib/utilities/raceStatus';
|
||||||
|
|
||||||
interface RaceCardProps {
|
interface RaceCardProps {
|
||||||
race: Race;
|
race: {
|
||||||
leagueName?: string;
|
id: string;
|
||||||
|
track: string;
|
||||||
|
car: string;
|
||||||
|
scheduledAt: string;
|
||||||
|
status: string;
|
||||||
|
leagueId?: string;
|
||||||
|
leagueName: string;
|
||||||
|
strengthOfField?: number | null;
|
||||||
|
};
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
compact?: boolean;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RaceCard({ race, leagueName, onClick, compact = false }: RaceCardProps) {
|
export function RaceCard({ race, onClick, className }: RaceCardProps) {
|
||||||
const statusConfig = {
|
const config = raceStatusConfig[race.status as keyof typeof 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',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`
|
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 || ''}`}
|
||||||
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' : ''}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
{/* Live indicator bar */}
|
{/* Live indicator */}
|
||||||
{race.status === 'running' && (
|
{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="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">
|
<div className="flex items-start gap-4">
|
||||||
{/* Left side - Race info */}
|
{/* 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-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<h3 className="text-lg font-semibold text-white truncate">{race.track}</h3>
|
<div className="min-w-0">
|
||||||
{/* Status badge */}
|
<h3 className="font-semibold text-white truncate group-hover:text-primary-blue transition-colors">
|
||||||
<div className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full ${config.bg} border ${config.border} flex-shrink-0`}>
|
{race.track}
|
||||||
{race.status === 'running' && (
|
</h3>
|
||||||
<span className="w-1.5 h-1.5 bg-performance-green rounded-full animate-pulse" />
|
<div className="flex items-center gap-3 mt-1">
|
||||||
)}
|
<span className="flex items-center gap-1 text-sm text-gray-400">
|
||||||
<StatusIcon className={`w-3.5 h-3.5 ${config.color}`} />
|
<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}`}>
|
<span className={`text-xs font-medium ${config.color}`}>
|
||||||
{config.label}
|
{config.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Meta info */}
|
{/* League Link */}
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-400">
|
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
|
||||||
<span className="flex items-center gap-1.5">
|
<Link
|
||||||
<Car className="w-3.5 h-3.5" />
|
href={`/leagues/${race.leagueId ?? ''}`}
|
||||||
{race.car}
|
onClick={(e) => e.stopPropagation()}
|
||||||
</span>
|
className="inline-flex items-center gap-2 text-sm text-primary-blue hover:underline"
|
||||||
{race.strengthOfField && (
|
>
|
||||||
<span className="flex items-center gap-1.5 text-warning-amber">
|
<Trophy className="w-3.5 h-3.5" />
|
||||||
<Zap className="w-3.5 h-3.5" />
|
{race.leagueName}
|
||||||
SOF {race.strengthOfField}
|
<ArrowRight className="w-3 h-3" />
|
||||||
</span>
|
</Link>
|
||||||
)}
|
|
||||||
{leagueName && (
|
|
||||||
<span className="flex items-center gap-1.5 text-primary-blue">
|
|
||||||
<Trophy className="w-3.5 h-3.5" />
|
|
||||||
{leagueName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Date/Time */}
|
{/* Arrow */}
|
||||||
<div className="text-right flex-shrink-0">
|
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-primary-blue transition-colors 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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
67
apps/website/components/races/RaceResultsHeader.tsx
Normal file
67
apps/website/components/races/RaceResultsHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
apps/website/components/races/RaceStats.tsx
Normal file
46
apps/website/components/races/RaceStats.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
apps/website/components/races/RaceStewardingStats.tsx
Normal file
36
apps/website/components/races/RaceStewardingStats.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
apps/website/components/races/SidebarRaceItem.tsx
Normal file
34
apps/website/components/races/SidebarRaceItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
apps/website/components/shared/EmptyState.tsx
Normal file
39
apps/website/components/shared/EmptyState.tsx
Normal 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>
|
||||||
|
);
|
||||||
103
apps/website/components/shared/HeroSection.tsx
Normal file
103
apps/website/components/shared/HeroSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
15
apps/website/components/shared/LoadingState.tsx
Normal file
15
apps/website/components/shared/LoadingState.tsx
Normal 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>
|
||||||
|
);
|
||||||
66
apps/website/components/shared/StatusBadge.tsx
Normal file
66
apps/website/components/shared/StatusBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
46
apps/website/components/social/FriendPill.tsx
Normal file
46
apps/website/components/social/FriendPill.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
apps/website/components/social/SocialHandles.tsx
Normal file
63
apps/website/components/social/SocialHandles.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
apps/website/components/sponsors/ActivityItem.tsx
Normal file
29
apps/website/components/sponsors/ActivityItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
apps/website/components/sponsors/MetricCard.tsx
Normal file
55
apps/website/components/sponsors/MetricCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
apps/website/components/sponsors/RenewalAlert.tsx
Normal file
41
apps/website/components/sponsors/RenewalAlert.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
apps/website/components/sponsors/SponsorshipCategoryCard.tsx
Normal file
42
apps/website/components/sponsors/SponsorshipCategoryCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
apps/website/components/teams/FeaturedRecruiting.tsx
Normal file
110
apps/website/components/teams/FeaturedRecruiting.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
apps/website/components/teams/SkillLevelSection.tsx
Normal file
98
apps/website/components/teams/SkillLevelSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
apps/website/components/teams/StatItem.tsx
Normal file
14
apps/website/components/teams/StatItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
apps/website/components/teams/TeamLeaderboardPreview.tsx
Normal file
175
apps/website/components/teams/TeamLeaderboardPreview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
apps/website/components/teams/TeamMembershipCard.tsx
Normal file
42
apps/website/components/teams/TeamMembershipCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
apps/website/components/teams/TopThreePodium.tsx
Normal file
175
apps/website/components/teams/TopThreePodium.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
apps/website/components/teams/WhyJoinTeamSection.tsx
Normal file
55
apps/website/components/teams/WhyJoinTeamSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/website/components/ui/TabContent.tsx
Normal file
15
apps/website/components/ui/TabContent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
apps/website/components/ui/TabNavigation.tsx
Normal file
39
apps/website/components/ui/TabNavigation.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface Tab {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabNavigationProps {
|
||||||
|
tabs: Tab[];
|
||||||
|
activeTab: string;
|
||||||
|
onTabChange: (tabId: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
apps/website/lib/utilities/country.ts
Normal file
8
apps/website/lib/utilities/country.ts
Normal 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 '🏁';
|
||||||
|
}
|
||||||
32
apps/website/lib/utilities/raceStatus.ts
Normal file
32
apps/website/lib/utilities/raceStatus.ts
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
81
apps/website/lib/utilities/time.ts
Normal file
81
apps/website/lib/utilities/time.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user