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 { useAuth } from '@/lib/auth/AuthContext';
|
||||
import AuthWorkflowMockup from '@/components/auth/AuthWorkflowMockup';
|
||||
import UserRolesPreview from '@/components/auth/UserRolesPreview';
|
||||
|
||||
interface FormErrors {
|
||||
email?: string;
|
||||
@@ -34,26 +35,6 @@ interface FormErrors {
|
||||
submit?: string;
|
||||
}
|
||||
|
||||
const USER_ROLES = [
|
||||
{
|
||||
icon: Car,
|
||||
title: 'Driver',
|
||||
description: 'Race, track stats, join teams',
|
||||
color: 'primary-blue',
|
||||
},
|
||||
{
|
||||
icon: Trophy,
|
||||
title: 'League Admin',
|
||||
description: 'Organize leagues and events',
|
||||
color: 'performance-green',
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Team Manager',
|
||||
description: 'Manage team and drivers',
|
||||
color: 'purple-400',
|
||||
},
|
||||
];
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -167,25 +148,7 @@ export default function LoginPage() {
|
||||
</p>
|
||||
|
||||
{/* Role Cards */}
|
||||
<div className="space-y-3 mb-8">
|
||||
{USER_ROLES.map((role, index) => (
|
||||
<motion.div
|
||||
key={role.title}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="flex items-center gap-4 p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline"
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg bg-${role.color}/20 flex items-center justify-center`}>
|
||||
<role.icon className={`w-5 h-5 text-${role.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-white font-medium">{role.title}</h4>
|
||||
<p className="text-sm text-gray-500">{role.description}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<UserRolesPreview variant="full" />
|
||||
|
||||
{/* Workflow Mockup */}
|
||||
<AuthWorkflowMockup />
|
||||
@@ -365,19 +328,7 @@ export default function LoginPage() {
|
||||
</p>
|
||||
|
||||
{/* Mobile Role Info */}
|
||||
<div className="mt-8 lg:hidden">
|
||||
<p className="text-center text-xs text-gray-500 mb-4">One account for all roles</p>
|
||||
<div className="flex justify-center gap-6">
|
||||
{USER_ROLES.map((role) => (
|
||||
<div key={role.title} className="flex flex-col items-center">
|
||||
<div className={`w-8 h-8 rounded-lg bg-${role.color}/20 flex items-center justify-center mb-1`}>
|
||||
<role.icon className={`w-4 h-4 text-${role.color}`} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{role.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<UserRolesPreview variant="compact" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -23,58 +23,15 @@ import {
|
||||
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { StatCard } from '@/components/dashboard/StatCard';
|
||||
import { LeagueStandingItem } from '@/components/dashboard/LeagueStandingItem';
|
||||
import { UpcomingRaceItem } from '@/components/dashboard/UpcomingRaceItem';
|
||||
import { FriendItem } from '@/components/dashboard/FriendItem';
|
||||
import { FeedItemRow } from '@/components/dashboard/FeedItemRow';
|
||||
|
||||
import { useDashboardOverview } from '@/hooks/useDashboardService';
|
||||
|
||||
|
||||
// Helper functions
|
||||
function getCountryFlag(countryCode: string): string {
|
||||
const code = countryCode.toUpperCase();
|
||||
if (code.length === 2) {
|
||||
const codePoints = [...code].map(char => 127397 + char.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
}
|
||||
return '🏁';
|
||||
}
|
||||
|
||||
function timeUntil(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = date.getTime() - now.getTime();
|
||||
|
||||
if (diffMs < 0) return 'Started';
|
||||
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffDays > 0) {
|
||||
return `${diffDays}d ${diffHours % 24}h`;
|
||||
}
|
||||
if (diffHours > 0) {
|
||||
const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
return `${diffHours}h ${diffMinutes}m`;
|
||||
}
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
return `${diffMinutes}m`;
|
||||
}
|
||||
|
||||
function timeAgo(timestamp: Date | string): string {
|
||||
const time = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
|
||||
const diffMs = Date.now() - time.getTime();
|
||||
const diffMinutes = Math.floor(diffMs / 60000);
|
||||
if (diffMinutes < 1) return 'Just now';
|
||||
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
function getGreeting(): string {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return 'Good morning';
|
||||
if (hour < 18) return 'Good afternoon';
|
||||
return 'Good evening';
|
||||
}
|
||||
import { getCountryFlag } from '@/lib/utilities/country';
|
||||
import { getGreeting, timeUntil, timeAgo } from '@/lib/utilities/time';
|
||||
|
||||
import { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
|
||||
|
||||
@@ -176,50 +133,10 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Quick Stats Row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
|
||||
<div className="p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/20">
|
||||
<Trophy className="w-5 h-5 text-performance-green" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{wins}</p>
|
||||
<p className="text-xs text-gray-500">Wins</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/20">
|
||||
<Medal className="w-5 h-5 text-warning-amber" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{podiums}</p>
|
||||
<p className="text-xs text-gray-500">Podiums</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/20">
|
||||
<Target className="w-5 h-5 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{consistency}%</p>
|
||||
<p className="text-xs text-gray-500">Consistency</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/20">
|
||||
<Users className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{activeLeaguesCount}</p>
|
||||
<p className="text-xs text-gray-500">Active Leagues</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StatCard icon={Trophy} value={wins} label="Wins" color="bg-performance-green/20 text-performance-green" />
|
||||
<StatCard icon={Medal} value={podiums} label="Podiums" color="bg-warning-amber/20 text-warning-amber" />
|
||||
<StatCard icon={Target} value={`${consistency}%`} label="Consistency" color="bg-primary-blue/20 text-primary-blue" />
|
||||
<StatCard icon={Users} value={activeLeaguesCount} label="Active Leagues" color="bg-purple-500/20 text-purple-400" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -300,38 +217,14 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{leagueStandingsSummaries.map(({ leagueId, leagueName, position, points, totalDrivers }) => (
|
||||
<Link
|
||||
<LeagueStandingItem
|
||||
key={leagueId}
|
||||
href={`/leagues/${leagueId}/standings`}
|
||||
className="flex items-center gap-4 p-4 rounded-xl bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/30 transition-colors group"
|
||||
>
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-xl font-bold text-xl ${
|
||||
position === 1 ? 'bg-yellow-400/20 text-yellow-400' :
|
||||
position === 2 ? 'bg-gray-300/20 text-gray-300' :
|
||||
position === 3 ? 'bg-orange-400/20 text-orange-400' :
|
||||
'bg-iron-gray text-gray-400'
|
||||
}`}>
|
||||
{position > 0 ? `P${position}` : '-'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-semibold truncate group-hover:text-primary-blue transition-colors">
|
||||
{leagueName}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{points} points • {totalDrivers} drivers
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{position <= 3 && position > 0 && (
|
||||
<Crown className={`w-5 h-5 ${
|
||||
position === 1 ? 'text-yellow-400' :
|
||||
position === 2 ? 'text-gray-300' :
|
||||
'text-orange-400'
|
||||
}`} />
|
||||
)}
|
||||
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-primary-blue transition-colors" />
|
||||
</div>
|
||||
</Link>
|
||||
leagueId={leagueId}
|
||||
leagueName={leagueName}
|
||||
position={position}
|
||||
points={points}
|
||||
totalDrivers={totalDrivers}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
@@ -376,30 +269,16 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
{upcomingRaces.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{upcomingRaces.slice(0, 5).map((race) => {
|
||||
const isMyRace = race.isMyLeague;
|
||||
return (
|
||||
<Link
|
||||
key={race.id}
|
||||
href={`/races/${race.id}`}
|
||||
className="block p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<p className="text-white font-medium text-sm truncate">{race.track}</p>
|
||||
{isMyRace && (
|
||||
<span className="flex-shrink-0 w-2 h-2 rounded-full bg-performance-green" title="Your league" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 truncate mb-2">{race.car}</p>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-400">
|
||||
{race.scheduledAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
<span className="text-primary-blue font-medium">{timeUntil(race.scheduledAt)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{upcomingRaces.slice(0, 5).map((race) => (
|
||||
<UpcomingRaceItem
|
||||
key={race.id}
|
||||
id={race.id}
|
||||
track={race.track}
|
||||
car={race.car}
|
||||
scheduledAt={race.scheduledAt}
|
||||
isMyLeague={race.isMyLeague}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm text-center py-4">No upcoming races</p>
|
||||
@@ -418,25 +297,13 @@ export default function DashboardPage() {
|
||||
{friends.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{friends.slice(0, 6).map((friend) => (
|
||||
<Link
|
||||
<FriendItem
|
||||
key={friend.id}
|
||||
href={`/drivers/${friend.id}`}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite transition-colors"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full overflow-hidden bg-gradient-to-br from-primary-blue to-purple-600">
|
||||
<Image
|
||||
src={friend.avatarUrl}
|
||||
alt={friend.name}
|
||||
width={36}
|
||||
height={36}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm font-medium truncate">{friend.name}</p>
|
||||
<p className="text-xs text-gray-500">{getCountryFlag(friend.country)}</p>
|
||||
</div>
|
||||
</Link>
|
||||
id={friend.id}
|
||||
name={friend.name}
|
||||
avatarUrl={friend.avatarUrl}
|
||||
country={friend.country}
|
||||
/>
|
||||
))}
|
||||
{friends.length > 6 && (
|
||||
<Link
|
||||
@@ -465,40 +332,3 @@ export default function DashboardPage() {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Feed Item Row Component
|
||||
function FeedItemRow({ item }: { item: DashboardFeedItemSummaryViewModel }) {
|
||||
const getActivityIcon = (type: string) => {
|
||||
if (type.includes('win')) return { icon: Trophy, color: 'text-yellow-400 bg-yellow-400/10' };
|
||||
if (type.includes('podium')) return { icon: Medal, color: 'text-warning-amber bg-warning-amber/10' };
|
||||
if (type.includes('join')) return { icon: UserPlus, color: 'text-performance-green bg-performance-green/10' };
|
||||
if (type.includes('friend')) return { icon: Heart, color: 'text-pink-400 bg-pink-400/10' };
|
||||
if (type.includes('league')) return { icon: Flag, color: 'text-primary-blue bg-primary-blue/10' };
|
||||
if (type.includes('race')) return { icon: Play, color: 'text-red-400 bg-red-400/10' };
|
||||
return { icon: Activity, color: 'text-gray-400 bg-gray-400/10' };
|
||||
};
|
||||
|
||||
const { icon: Icon, color } = getActivityIcon(item.type);
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 p-3 rounded-lg bg-deep-graphite/50 border border-charcoal-outline">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${color} flex-shrink-0`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white">{item.headline}</p>
|
||||
{item.body && (
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{item.body}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-1">{timeAgo(item.timestamp)}</p>
|
||||
</div>
|
||||
{item.ctaHref && (
|
||||
<Link href={item.ctaHref} className="flex-shrink-0">
|
||||
<Button variant="secondary" className="text-xs px-3 py-1.5">
|
||||
{item.ctaLabel || 'View'}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Card from '@/components/ui/Card';
|
||||
import PointsTable from '@/components/leagues/PointsTable';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
|
||||
|
||||
@@ -124,49 +125,7 @@ export default function LeagueRulebookPage() {
|
||||
</div>
|
||||
|
||||
{/* Points Table */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Points Distribution</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-charcoal-outline">
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-400">Position</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-400">Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positionPoints.map(({ position, points }) => (
|
||||
<tr
|
||||
key={position}
|
||||
className={`border-b border-charcoal-outline/50 transition-colors hover:bg-iron-gray/30 ${
|
||||
position <= 3 ? 'bg-iron-gray/20' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||
position === 1 ? 'bg-yellow-500 text-black' :
|
||||
position === 2 ? 'bg-gray-400 text-black' :
|
||||
position === 3 ? 'bg-amber-600 text-white' :
|
||||
'bg-charcoal-outline text-white'
|
||||
}`}>
|
||||
{position}
|
||||
</div>
|
||||
<span className="text-white font-medium">
|
||||
{position === 1 ? '1st' : position === 2 ? '2nd' : position === 3 ? '3rd' : `${position}th`}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<span className="text-white font-semibold tabular-nums">{points}</span>
|
||||
<span className="text-gray-500 ml-1">pts</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<PointsTable points={positionPoints} />
|
||||
|
||||
{/* Bonus Points */}
|
||||
{primaryChampionship?.bonusSummary && primaryChampionship.bonusSummary.length > 0 && (
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ReadonlyLeagueInfo } from '@/components/leagues/ReadonlyLeagueInfo';
|
||||
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
|
||||
import Button from '@/components/ui/Button';
|
||||
import LeagueOwnershipTransfer from '@/components/leagues/LeagueOwnershipTransfer';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||
@@ -22,9 +21,6 @@ export default function LeagueSettingsPage() {
|
||||
const [settings, setSettings] = useState<LeagueSettingsViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [showTransferDialog, setShowTransferDialog] = useState(false);
|
||||
const [selectedNewOwner, setSelectedNewOwner] = useState<string>('');
|
||||
const [transferring, setTransferring] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -58,20 +54,12 @@ export default function LeagueSettingsPage() {
|
||||
|
||||
const ownerSummary = settings?.owner || null;
|
||||
|
||||
const handleTransferOwnership = async () => {
|
||||
if (!selectedNewOwner || !settings) return;
|
||||
|
||||
setTransferring(true);
|
||||
const handleTransferOwnership = async (newOwnerId: string) => {
|
||||
try {
|
||||
await leagueSettingsService.transferOwnership(leagueId, currentDriverId, selectedNewOwner);
|
||||
|
||||
setShowTransferDialog(false);
|
||||
await leagueSettingsService.transferOwnership(leagueId, currentDriverId, newOwnerId);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error('Failed to transfer ownership:', err);
|
||||
alert(err instanceof Error ? err.message : 'Failed to transfer ownership');
|
||||
} finally {
|
||||
setTransferring(false);
|
||||
throw err; // Let the component handle the error
|
||||
}
|
||||
};
|
||||
|
||||
@@ -128,76 +116,11 @@ export default function LeagueSettingsPage() {
|
||||
<div className="space-y-4">
|
||||
<ReadonlyLeagueInfo league={settings.league} configForm={settings.config} />
|
||||
|
||||
{/* League Owner - Compact */}
|
||||
<div className="rounded-xl border border-charcoal-outline bg-gradient-to-br from-iron-gray/40 to-iron-gray/20 p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-400 mb-3">League Owner</h3>
|
||||
{ownerSummary ? (
|
||||
<DriverSummaryPill
|
||||
driver={ownerSummary.driver}
|
||||
rating={ownerSummary.rating}
|
||||
rank={ownerSummary.rank}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Loading owner details...</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transfer Ownership - Owner Only */}
|
||||
{settings.league.ownerId === currentDriverId && settings.members.length > 0 && (
|
||||
<div className="rounded-xl border border-charcoal-outline bg-gradient-to-br from-iron-gray/40 to-iron-gray/20 p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<UserCog className="w-4 h-4 text-gray-400" />
|
||||
<h3 className="text-sm font-semibold text-gray-400">Transfer Ownership</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Transfer league ownership to another active member. You will become an admin.
|
||||
</p>
|
||||
|
||||
{!showTransferDialog ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowTransferDialog(true)}
|
||||
>
|
||||
Transfer Ownership
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={selectedNewOwner}
|
||||
onChange={(e) => setSelectedNewOwner(e.target.value)}
|
||||
className="w-full rounded-lg border border-charcoal-outline bg-charcoal-card px-3 py-2 text-sm text-white focus:border-primary-blue focus:outline-none"
|
||||
>
|
||||
<option value="">Select new owner...</option>
|
||||
{settings.members.map((member) => (
|
||||
<option key={member.driver.id} value={member.driver.id}>
|
||||
{member.driver.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleTransferOwnership}
|
||||
disabled={!selectedNewOwner || transferring}
|
||||
>
|
||||
{transferring ? 'Transferring...' : 'Confirm Transfer'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowTransferDialog(false);
|
||||
setSelectedNewOwner('');
|
||||
}}
|
||||
disabled={transferring}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<LeagueOwnershipTransfer
|
||||
settings={settings}
|
||||
currentDriverId={currentDriverId}
|
||||
onTransferOwnership={handleTransferOwnership}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import StandingsTable from '@/components/leagues/StandingsTable';
|
||||
import LeagueChampionshipStats from '@/components/leagues/LeagueChampionshipStats';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import type { LeagueMembership, MembershipRole } from '@/lib/types';
|
||||
@@ -86,54 +87,10 @@ export default function LeagueStandingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const leader = standings[0];
|
||||
const totalRaces = Math.max(...standings.map(s => s.races), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Championship Stats */}
|
||||
{standings.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-yellow-500/10 flex items-center justify-center">
|
||||
<span className="text-2xl">🏆</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1">Championship Leader</p>
|
||||
<p className="font-bold text-white">{drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'}</p>
|
||||
<p className="text-sm text-yellow-400 font-medium">{leader?.points || 0} points</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-primary-blue/10 flex items-center justify-center">
|
||||
<span className="text-2xl">🏁</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1">Races Completed</p>
|
||||
<p className="text-2xl font-bold text-white">{totalRaces}</p>
|
||||
<p className="text-sm text-gray-400">Season in progress</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-performance-green/10 flex items-center justify-center">
|
||||
<span className="text-2xl">👥</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1">Active Drivers</p>
|
||||
<p className="text-2xl font-bold text-white">{standings.length}</p>
|
||||
<p className="text-sm text-gray-400">Competing for points</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
<LeagueChampionshipStats standings={standings} drivers={drivers} />
|
||||
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Championship Standings</h2>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import PenaltyFAB from '@/components/leagues/PenaltyFAB';
|
||||
import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal';
|
||||
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
|
||||
import StewardingStats from '@/components/leagues/StewardingStats';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
@@ -183,29 +184,11 @@ export default function LeagueStewardingPage() {
|
||||
|
||||
{/* Stats summary */}
|
||||
{!loading && stewardingData && (
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
|
||||
<div className="flex items-center gap-2 text-warning-amber mb-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="text-xs font-medium uppercase">Pending Review</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{stewardingData.totalPending}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
|
||||
<div className="flex items-center gap-2 text-performance-green mb-1">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span className="text-xs font-medium uppercase">Resolved</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{stewardingData.totalResolved}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
|
||||
<div className="flex items-center gap-2 text-red-400 mb-1">
|
||||
<Gavel className="w-4 h-4" />
|
||||
<span className="text-xs font-medium uppercase">Penalties</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{stewardingData.totalPenalties}</div>
|
||||
</div>
|
||||
</div>
|
||||
<StewardingStats
|
||||
totalPending={stewardingData.totalPending}
|
||||
totalResolved={stewardingData.totalResolved}
|
||||
totalPenalties={stewardingData.totalPenalties}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tab navigation */}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import TransactionRow from '@/components/leagues/TransactionRow';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
|
||||
import {
|
||||
@@ -21,69 +22,6 @@ import {
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
|
||||
function TransactionRow({ transaction }: { transaction: any }) {
|
||||
const isIncoming = transaction.amount > 0;
|
||||
|
||||
const typeIcons = {
|
||||
sponsorship: DollarSign,
|
||||
membership: CreditCard,
|
||||
withdrawal: ArrowUpRight,
|
||||
prize: TrendingUp,
|
||||
};
|
||||
const TypeIcon = typeIcons[transaction.type];
|
||||
|
||||
const statusConfig = {
|
||||
completed: { color: 'text-performance-green', bg: 'bg-performance-green/10', icon: CheckCircle },
|
||||
pending: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', icon: Clock },
|
||||
failed: { color: 'text-racing-red', bg: 'bg-racing-red/10', icon: XCircle },
|
||||
};
|
||||
const status = statusConfig[transaction.status];
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline last:border-b-0 hover:bg-iron-gray/30 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${isIncoming ? 'bg-performance-green/10' : 'bg-iron-gray/50'}`}>
|
||||
{isIncoming ? (
|
||||
<ArrowDownLeft className="w-5 h-5 text-performance-green" />
|
||||
) : (
|
||||
<ArrowUpRight className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white">{transaction.description}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${status.bg} ${status.color}`}>
|
||||
{transaction.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 mt-1">
|
||||
<TypeIcon className="w-3 h-3" />
|
||||
<span className="capitalize">{transaction.type}</span>
|
||||
{transaction.reference && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{transaction.reference}</span>
|
||||
</>
|
||||
)}
|
||||
<span>•</span>
|
||||
<span>{transaction.formattedDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`font-semibold ${isIncoming ? 'text-performance-green' : 'text-white'}`}>
|
||||
{transaction.formattedAmount}
|
||||
</div>
|
||||
{transaction.fee > 0 && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Fee: ${transaction.fee.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LeagueWalletPage() {
|
||||
const params = useParams();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal';
|
||||
import ImportResultsForm from '@/components/races/ImportResultsForm';
|
||||
import RaceResultsHeader from '@/components/races/RaceResultsHeader';
|
||||
import ResultsTable from '@/components/races/ResultsTable';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
@@ -132,50 +133,13 @@ export default function RaceResultsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden rounded-2xl bg-gray-500/10 border border-gray-500/30 p-6 sm:p-8">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full blur-3xl" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-performance-green/10 border border-performance-green/30">
|
||||
<Trophy className="w-4 h-4 text-performance-green" />
|
||||
<span className="text-sm font-semibold text-performance-green">
|
||||
Final Results
|
||||
</span>
|
||||
</div>
|
||||
{raceSOF && (
|
||||
<span className="flex items-center gap-1.5 text-warning-amber text-sm">
|
||||
<Zap className="w-4 h-4" />
|
||||
SOF {raceSOF}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-white mb-2">
|
||||
{raceData?.race?.track ?? 'Race'} Results
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-gray-400">
|
||||
{raceData?.race && (
|
||||
<>
|
||||
<span className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{new Date(raceData.race.scheduledAt).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
{raceData.stats.totalDrivers} drivers classified
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{raceData?.league && <span className="text-primary-blue">{raceData.league.name}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<RaceResultsHeader
|
||||
raceTrack={raceData?.race?.track}
|
||||
raceScheduledAt={raceData?.race?.scheduledAt}
|
||||
totalDrivers={raceData?.stats.totalDrivers}
|
||||
leagueName={raceData?.league?.name}
|
||||
raceSOF={raceSOF}
|
||||
/>
|
||||
|
||||
{importSuccess && (
|
||||
<div className="p-4 bg-performance-green/10 border border-performance-green/30 rounded-lg text-performance-green">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import RaceStewardingStats from '@/components/races/RaceStewardingStats';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
@@ -154,29 +155,11 @@ export default function RaceStewardingPage() {
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="rounded-lg bg-deep-graphite/50 border border-charcoal-outline p-4">
|
||||
<div className="flex items-center gap-2 text-warning-amber mb-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="text-xs font-medium uppercase">Pending</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{stewardingData?.pendingCount ?? 0}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-deep-graphite/50 border border-charcoal-outline p-4">
|
||||
<div className="flex items-center gap-2 text-performance-green mb-1">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span className="text-xs font-medium uppercase">Resolved</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{stewardingData?.resolvedCount ?? 0}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-deep-graphite/50 border border-charcoal-outline p-4">
|
||||
<div className="flex items-center gap-2 text-red-400 mb-1">
|
||||
<Gavel className="w-4 h-4" />
|
||||
<span className="text-xs font-medium uppercase">Penalties</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{stewardingData?.penaltiesCount ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
<RaceStewardingStats
|
||||
pendingCount={stewardingData?.pendingCount ?? 0}
|
||||
resolvedCount={stewardingData?.resolvedCount ?? 0}
|
||||
penaltiesCount={stewardingData?.penaltiesCount ?? 0}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
|
||||
@@ -6,6 +6,10 @@ import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import InfoBanner from '@/components/ui/InfoBanner';
|
||||
import MetricCard from '@/components/sponsors/MetricCard';
|
||||
import SponsorshipCategoryCard from '@/components/sponsors/SponsorshipCategoryCard';
|
||||
import ActivityItem from '@/components/sponsors/ActivityItem';
|
||||
import RenewalAlert from '@/components/sponsors/RenewalAlert';
|
||||
import {
|
||||
BarChart3,
|
||||
Eye,
|
||||
@@ -35,146 +39,9 @@ import { SponsorService } from '@/lib/services/sponsors/SponsorService';
|
||||
import { SponsorDashboardViewModel } from '@/lib/view-models/SponsorDashboardViewModel';
|
||||
import { ServiceFactory } from '@/lib/services/ServiceFactory';
|
||||
|
||||
// Metric Card Component
|
||||
function MetricCard({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
icon: Icon,
|
||||
suffix = '',
|
||||
prefix = '',
|
||||
delay = 0,
|
||||
}: {
|
||||
title: string;
|
||||
value: number | string;
|
||||
change?: number;
|
||||
icon: typeof Eye;
|
||||
suffix?: string;
|
||||
prefix?: string;
|
||||
delay?: number;
|
||||
}) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const isPositive = change && change > 0;
|
||||
const isNegative = change && change < 0;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay }}
|
||||
>
|
||||
<Card className="p-5 h-full">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary-blue/10">
|
||||
<Icon className="w-5 h-5 text-primary-blue" />
|
||||
</div>
|
||||
{change !== undefined && (
|
||||
<div className={`flex items-center gap-1 text-sm font-medium ${
|
||||
isPositive ? 'text-performance-green' : isNegative ? 'text-racing-red' : 'text-gray-400'
|
||||
}`}>
|
||||
{isPositive ? <ArrowUpRight className="w-4 h-4" /> : isNegative ? <ArrowDownRight className="w-4 h-4" /> : null}
|
||||
{Math.abs(change)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white mb-1">
|
||||
{prefix}{typeof value === 'number' ? value.toLocaleString() : value}{suffix}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">{title}</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sponsorship Category Card
|
||||
function SponsorshipCategoryCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
count,
|
||||
impressions,
|
||||
color,
|
||||
href
|
||||
}: {
|
||||
icon: typeof Trophy;
|
||||
title: string;
|
||||
count: number;
|
||||
impressions: number;
|
||||
color: string;
|
||||
href: string;
|
||||
}) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<Card className="p-4 hover:border-primary-blue/50 transition-all duration-300 cursor-pointer group">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg bg-iron-gray flex items-center justify-center group-hover:bg-primary-blue/10 transition-colors`}>
|
||||
<Icon className={`w-5 h-5 ${color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">{title}</p>
|
||||
<p className="text-sm text-gray-500">{count} active</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-white font-semibold">{impressions.toLocaleString()}</p>
|
||||
<p className="text-xs text-gray-500">impressions</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Activity Item
|
||||
function ActivityItem({ activity }: { activity: any }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-3 border-b border-charcoal-outline/50 last:border-b-0">
|
||||
<div className={`w-2 h-2 rounded-full mt-2 ${activity.typeColor}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white truncate">{activity.message}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-gray-500">{activity.time}</span>
|
||||
{activity.formattedImpressions && (
|
||||
<>
|
||||
<span className="text-xs text-gray-600">•</span>
|
||||
<span className="text-xs text-gray-400">{activity.formattedImpressions} views</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Renewal Alert
|
||||
function RenewalAlert({ renewal }: { renewal: any }) {
|
||||
const typeIcons = {
|
||||
league: Trophy,
|
||||
team: Users,
|
||||
driver: Car,
|
||||
race: Flag,
|
||||
platform: Megaphone,
|
||||
};
|
||||
const Icon = typeIcons[renewal.type] || Trophy;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="w-4 h-4 text-warning-amber" />
|
||||
<div>
|
||||
<p className="text-sm text-white">{renewal.name}</p>
|
||||
<p className="text-xs text-gray-400">Renews {renewal.formattedRenewDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-white">{renewal.formattedPrice}</p>
|
||||
<Button variant="secondary" className="text-xs mt-1 py-1 px-2 min-h-0">
|
||||
Renew
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SponsorDashboardPage() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
@@ -12,6 +12,7 @@ import JoinTeamButton from '@/components/teams/JoinTeamButton';
|
||||
import TeamAdmin from '@/components/teams/TeamAdmin';
|
||||
import TeamRoster from '@/components/teams/TeamRoster';
|
||||
import TeamStandings from '@/components/teams/TeamStandings';
|
||||
import StatItem from '@/components/teams/StatItem';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
|
||||
import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
|
||||
@@ -319,12 +320,3 @@ export default function TeamDetailPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({ label, value, color }: { label: string; value: string; color: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400 text-sm">{label}</span>
|
||||
<span className={`font-semibold ${color}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import TopThreePodium from '@/components/teams/TopThreePodium';
|
||||
import { useAllTeams } from '@/hooks/useTeamService';
|
||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
|
||||
@@ -109,146 +110,6 @@ const SORT_OPTIONS: { id: SortBy; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'races', label: 'Races', icon: Hash },
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// TOP THREE PODIUM COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
interface TopThreePodiumProps {
|
||||
teams: TeamDisplayData[];
|
||||
onTeamClick: (teamId: string) => void;
|
||||
}
|
||||
|
||||
function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) {
|
||||
const top3 = teams.slice(0, 3) as [TeamDisplayData, TeamDisplayData, TeamDisplayData];
|
||||
if (teams.length < 3) return null;
|
||||
|
||||
// Display order: 2nd, 1st, 3rd
|
||||
const podiumOrder: [TeamDisplayData, TeamDisplayData, TeamDisplayData] = [
|
||||
top3[1],
|
||||
top3[0],
|
||||
top3[2],
|
||||
];
|
||||
const podiumHeights = ['h-28', 'h-36', 'h-20'];
|
||||
const podiumPositions = [2, 1, 3];
|
||||
|
||||
const getPositionColor = (position: number) => {
|
||||
switch (position) {
|
||||
case 1:
|
||||
return 'text-yellow-400';
|
||||
case 2:
|
||||
return 'text-gray-300';
|
||||
case 3:
|
||||
return 'text-amber-600';
|
||||
default:
|
||||
return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getGradient = (position: number) => {
|
||||
switch (position) {
|
||||
case 1:
|
||||
return 'from-yellow-400/30 via-yellow-500/20 to-yellow-600/10';
|
||||
case 2:
|
||||
return 'from-gray-300/30 via-gray-400/20 to-gray-500/10';
|
||||
case 3:
|
||||
return 'from-amber-500/30 via-amber-600/20 to-amber-700/10';
|
||||
default:
|
||||
return 'from-gray-600/30 to-gray-700/10';
|
||||
}
|
||||
};
|
||||
|
||||
const getBorderColor = (position: number) => {
|
||||
switch (position) {
|
||||
case 1:
|
||||
return 'border-yellow-400/50';
|
||||
case 2:
|
||||
return 'border-gray-300/50';
|
||||
case 3:
|
||||
return 'border-amber-600/50';
|
||||
default:
|
||||
return 'border-charcoal-outline';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-10 p-8 rounded-2xl bg-gradient-to-br from-iron-gray/60 to-iron-gray/30 border border-charcoal-outline">
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
<Trophy className="w-6 h-6 text-yellow-400" />
|
||||
<h2 className="text-xl font-bold text-white">Top 3 Teams</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-center gap-4 md:gap-8">
|
||||
{podiumOrder.map((team, index) => {
|
||||
const position = podiumPositions[index] ?? 0;
|
||||
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
|
||||
const LevelIcon = levelConfig?.icon || Shield;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={team.id}
|
||||
type="button"
|
||||
onClick={() => onTeamClick(team.id)}
|
||||
className="flex flex-col items-center group"
|
||||
>
|
||||
{/* Team card */}
|
||||
<div
|
||||
className={`relative mb-4 p-4 rounded-xl bg-gradient-to-br ${getGradient(position ?? 0)} border ${getBorderColor(position ?? 0)} transition-all group-hover:scale-105 group-hover:shadow-lg`}
|
||||
>
|
||||
{/* Crown for 1st place */}
|
||||
{position === 1 && (
|
||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2">
|
||||
<div className="relative">
|
||||
<Crown className="w-8 h-8 text-yellow-400 animate-pulse" />
|
||||
<div className="absolute inset-0 w-8 h-8 bg-yellow-400/30 blur-md rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team icon */}
|
||||
<div
|
||||
className={`flex h-16 w-16 md:h-20 md:w-20 items-center justify-center rounded-xl ${levelConfig?.bgColor} border ${levelConfig?.borderColor} mb-3`}
|
||||
>
|
||||
<LevelIcon className={`w-8 h-8 md:w-10 md:h-10 ${levelConfig?.color}`} />
|
||||
</div>
|
||||
|
||||
{/* Team name */}
|
||||
<p className="text-white font-bold text-sm md:text-base text-center max-w-[120px] truncate group-hover:text-purple-400 transition-colors">
|
||||
{team.name}
|
||||
</p>
|
||||
|
||||
{/* Rating */}
|
||||
<p className={`text-lg md:text-xl font-mono font-bold ${getPositionColor(position)} text-center`}>
|
||||
{getSafeRating(team).toLocaleString()}
|
||||
</p>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="flex items-center justify-center gap-3 mt-2 text-xs text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Trophy className="w-3 h-3 text-performance-green" />
|
||||
{getSafeTotalWins(team)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3 text-purple-400" />
|
||||
{team.memberCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Podium stand */}
|
||||
<div
|
||||
className={`${podiumHeights[index]} w-20 md:w-28 rounded-t-lg bg-gradient-to-t ${getGradient(position)} border-t border-x ${getBorderColor(position)} flex items-start justify-center pt-3`}
|
||||
>
|
||||
<span className={`text-2xl md:text-3xl font-bold ${getPositionColor(position)}`}>
|
||||
{position}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN PAGE COMPONENT
|
||||
@@ -455,7 +316,7 @@ export default function TeamLeaderboardPage() {
|
||||
|
||||
{/* Podium for Top 3 - only show when viewing by rating without filters */}
|
||||
{sortBy === 'rating' && filterLevel === 'all' && !searchQuery && filteredAndSortedTeams.length >= 3 && (
|
||||
<TopThreePodium teams={filteredAndSortedTeams} onTeamClick={handleTeamClick} />
|
||||
<TopThreePodium teams={filteredAndSortedTeams} onClick={handleTeamClick} />
|
||||
)}
|
||||
|
||||
{/* Stats Summary */}
|
||||
|
||||
@@ -28,6 +28,10 @@ import Card from '@/components/ui/Card';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import CreateTeamForm from '@/components/teams/CreateTeamForm';
|
||||
import WhyJoinTeamSection from '@/components/teams/WhyJoinTeamSection';
|
||||
import SkillLevelSection from '@/components/teams/SkillLevelSection';
|
||||
import FeaturedRecruiting from '@/components/teams/FeaturedRecruiting';
|
||||
import TeamLeaderboardPreview from '@/components/teams/TeamLeaderboardPreview';
|
||||
import { useAllTeams } from '@/hooks/useTeamService';
|
||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
|
||||
@@ -88,341 +92,9 @@ const SKILL_LEVELS: {
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// WHY JOIN A TEAM SECTION
|
||||
// ============================================================================
|
||||
|
||||
function WhyJoinTeamSection() {
|
||||
const benefits = [
|
||||
{
|
||||
icon: Handshake,
|
||||
title: 'Shared Strategy',
|
||||
description: 'Develop setups together, share telemetry, and coordinate pit strategies for endurance races.',
|
||||
},
|
||||
{
|
||||
icon: MessageCircle,
|
||||
title: 'Team Communication',
|
||||
description: 'Discord integration, voice chat during races, and dedicated team channels.',
|
||||
},
|
||||
{
|
||||
icon: Calendar,
|
||||
title: 'Coordinated Schedule',
|
||||
description: 'Team calendars, practice sessions, and organized race attendance.',
|
||||
},
|
||||
{
|
||||
icon: Trophy,
|
||||
title: 'Team Championships',
|
||||
description: 'Compete in team-based leagues and build your collective reputation.',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mb-12">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Why Join a Team?</h2>
|
||||
<p className="text-gray-400">Racing is better when you have teammates to share the journey</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{benefits.map((benefit) => (
|
||||
<div
|
||||
key={benefit.title}
|
||||
className="p-5 rounded-xl bg-iron-gray/50 border border-charcoal-outline/50 hover:border-purple-500/30 transition-all duration-300"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10 border border-purple-500/20 mb-3">
|
||||
<benefit.icon className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<h3 className="text-white font-semibold mb-1">{benefit.title}</h3>
|
||||
<p className="text-sm text-gray-500">{benefit.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SKILL LEVEL SECTION COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
interface SkillLevelSectionProps {
|
||||
level: typeof SKILL_LEVELS[0];
|
||||
teams: TeamDisplayData[];
|
||||
onTeamClick: (id: string) => void;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
function SkillLevelSection({ level, teams, onTeamClick, defaultExpanded = false }: SkillLevelSectionProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
const recruitingTeams = teams.filter((t) => t.isRecruiting);
|
||||
const displayedTeams = isExpanded ? teams : teams.slice(0, 3);
|
||||
const Icon = level.icon;
|
||||
|
||||
if (teams.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex h-11 w-11 items-center justify-center rounded-xl ${level.bgColor} border ${level.borderColor}`}>
|
||||
<Icon className={`w-5 h-5 ${level.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold text-white">{level.label}</h2>
|
||||
<span className="px-2 py-0.5 rounded-full text-xs bg-charcoal-outline/50 text-gray-400">
|
||||
{teams.length} {teams.length === 1 ? 'team' : 'teams'}
|
||||
</span>
|
||||
{recruitingTeams.length > 0 && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-performance-green/10 text-performance-green border border-performance-green/20">
|
||||
<UserPlus className="w-3 h-3" />
|
||||
{recruitingTeams.length} recruiting
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{level.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{teams.length > 3 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm text-gray-400 hover:text-white hover:bg-iron-gray/50 transition-all"
|
||||
>
|
||||
{isExpanded ? 'Show less' : `View all ${teams.length}`}
|
||||
<ChevronRight className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Teams Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{displayedTeams.map((team) => (
|
||||
<TeamCard
|
||||
key={team.id}
|
||||
id={team.id}
|
||||
name={team.name}
|
||||
description={team.description ?? ''}
|
||||
memberCount={team.memberCount}
|
||||
rating={team.rating}
|
||||
totalWins={team.totalWins}
|
||||
totalRaces={team.totalRaces}
|
||||
performanceLevel={team.performanceLevel}
|
||||
isRecruiting={team.isRecruiting}
|
||||
specialization={team.specialization}
|
||||
region={team.region ?? ''}
|
||||
languages={team.languages}
|
||||
onClick={() => onTeamClick(team.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FEATURED RECRUITING TEAMS
|
||||
// ============================================================================
|
||||
|
||||
interface FeaturedRecruitingProps {
|
||||
teams: TeamDisplayData[];
|
||||
onTeamClick: (id: string) => void;
|
||||
}
|
||||
|
||||
function FeaturedRecruiting({ teams, onTeamClick }: FeaturedRecruitingProps) {
|
||||
const recruitingTeams = teams.filter((t) => t.isRecruiting).slice(0, 4);
|
||||
|
||||
if (recruitingTeams.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-10">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-performance-green/10 border border-performance-green/20">
|
||||
<UserPlus className="w-5 h-5 text-performance-green" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Looking for Drivers</h2>
|
||||
<p className="text-xs text-gray-500">Teams actively recruiting new members</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{recruitingTeams.map((team) => {
|
||||
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
|
||||
const LevelIcon = levelConfig?.icon || Shield;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={team.id}
|
||||
type="button"
|
||||
onClick={() => onTeamClick(team.id)}
|
||||
className="p-4 rounded-xl bg-iron-gray/60 border border-charcoal-outline hover:border-performance-green/40 transition-all duration-200 text-left group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${levelConfig?.bgColor} border ${levelConfig?.borderColor}`}>
|
||||
<LevelIcon className={`w-4 h-4 ${levelConfig?.color}`} />
|
||||
</div>
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] bg-performance-green/10 text-performance-green border border-performance-green/20">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-performance-green animate-pulse" />
|
||||
Recruiting
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-white font-semibold mb-1 group-hover:text-performance-green transition-colors line-clamp-1">
|
||||
{team.name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 line-clamp-2 mb-3">{team.description}</p>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{team.memberCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Trophy className="w-3 h-3" />
|
||||
{team.totalWins} wins
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEAM LEADERBOARD PREVIEW COMPONENT (Top 5 + Link)
|
||||
// ============================================================================
|
||||
|
||||
interface TeamLeaderboardPreviewProps {
|
||||
topTeams: TeamDisplayData[];
|
||||
onTeamClick: (id: string) => void;
|
||||
}
|
||||
|
||||
function TeamLeaderboardPreview({ topTeams, onTeamClick }: TeamLeaderboardPreviewProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const getMedalColor = (position: number) => {
|
||||
switch (position) {
|
||||
case 0:
|
||||
return 'text-yellow-400';
|
||||
case 1:
|
||||
return 'text-gray-300';
|
||||
case 2:
|
||||
return 'text-amber-600';
|
||||
default:
|
||||
return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getMedalBg = (position: number) => {
|
||||
switch (position) {
|
||||
case 0:
|
||||
return 'bg-yellow-400/10 border-yellow-400/30';
|
||||
case 1:
|
||||
return 'bg-gray-300/10 border-gray-300/30';
|
||||
case 2:
|
||||
return 'bg-amber-600/10 border-amber-600/30';
|
||||
default:
|
||||
return 'bg-iron-gray/50 border-charcoal-outline';
|
||||
}
|
||||
};
|
||||
|
||||
if (topTeams.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-12">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-yellow-400/20 to-amber-600/10 border border-yellow-400/30">
|
||||
<Award className="w-5 h-5 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">Top Teams</h2>
|
||||
<p className="text-sm text-gray-500">Highest rated racing teams</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push('/teams/leaderboard')}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
View Full Leaderboard
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Compact Leaderboard */}
|
||||
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
|
||||
<div className="divide-y divide-charcoal-outline/50">
|
||||
{topTeams.map((team, index) => {
|
||||
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
|
||||
const LevelIcon = levelConfig?.icon || Shield;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={team.id}
|
||||
type="button"
|
||||
onClick={() => onTeamClick(team.id)}
|
||||
className="flex items-center gap-4 px-4 py-3 w-full text-left hover:bg-iron-gray/30 transition-colors group"
|
||||
>
|
||||
{/* Position */}
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold border ${getMedalBg(index)} ${getMedalColor(index)}`}
|
||||
>
|
||||
{index < 3 ? (
|
||||
<Crown className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Team Info */}
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded-lg ${levelConfig?.bgColor} border ${levelConfig?.borderColor}`}>
|
||||
<LevelIcon className={`w-4 h-4 ${levelConfig?.color}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium truncate group-hover:text-purple-400 transition-colors">
|
||||
{team.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{team.memberCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Trophy className="w-3 h-3" />
|
||||
{team.totalWins} wins
|
||||
</span>
|
||||
{team.isRecruiting && (
|
||||
<span className="flex items-center gap-1 text-performance-green">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-performance-green animate-pulse" />
|
||||
Recruiting
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rating */}
|
||||
<div className="text-right">
|
||||
<p className="text-purple-400 font-mono font-semibold">
|
||||
{team.rating?.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Rating</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN PAGE COMPONENT
|
||||
|
||||
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 { Race } from '@core/racing/domain/entities/Race';
|
||||
import { Clock, PlayCircle, CheckCircle2, XCircle, Zap, Car, Trophy } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { ChevronRight, Car, Zap, Trophy, ArrowRight } from 'lucide-react';
|
||||
import { formatTime, getRelativeTime } from '@/lib/utilities/time';
|
||||
import { raceStatusConfig } from '@/lib/utilities/raceStatus';
|
||||
|
||||
interface RaceCardProps {
|
||||
race: Race;
|
||||
leagueName?: string;
|
||||
race: {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: string;
|
||||
leagueId?: string;
|
||||
leagueName: string;
|
||||
strengthOfField?: number | null;
|
||||
};
|
||||
onClick?: () => void;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function RaceCard({ race, leagueName, onClick, compact = false }: RaceCardProps) {
|
||||
const statusConfig = {
|
||||
scheduled: {
|
||||
icon: Clock,
|
||||
color: 'text-primary-blue',
|
||||
bg: 'bg-primary-blue/10',
|
||||
border: 'border-primary-blue/30',
|
||||
label: 'Scheduled',
|
||||
},
|
||||
running: {
|
||||
icon: PlayCircle,
|
||||
color: 'text-performance-green',
|
||||
bg: 'bg-performance-green/10',
|
||||
border: 'border-performance-green/30',
|
||||
label: 'LIVE',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-gray-400',
|
||||
bg: 'bg-gray-500/10',
|
||||
border: 'border-gray-500/30',
|
||||
label: 'Completed',
|
||||
},
|
||||
cancelled: {
|
||||
icon: XCircle,
|
||||
color: 'text-warning-amber',
|
||||
bg: 'bg-warning-amber/10',
|
||||
border: 'border-warning-amber/30',
|
||||
label: 'Cancelled',
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[race.status];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return new Date(date).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
};
|
||||
|
||||
const getRelativeTime = (date: Date) => {
|
||||
const now = new Date();
|
||||
const targetDate = new Date(date);
|
||||
const diffMs = targetDate.getTime() - now.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMs < 0) return null;
|
||||
if (diffHours < 1) return 'Starting soon';
|
||||
if (diffHours < 24) return `In ${diffHours}h`;
|
||||
if (diffDays === 1) return 'Tomorrow';
|
||||
if (diffDays < 7) return `In ${diffDays} days`;
|
||||
return null;
|
||||
};
|
||||
|
||||
const relativeTime = race.status === 'scheduled' ? getRelativeTime(race.scheduledAt) : null;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`
|
||||
relative overflow-hidden p-4 rounded-lg bg-iron-gray border ${config.border}
|
||||
transition-all duration-200
|
||||
${onClick ? 'cursor-pointer hover:scale-[1.02] hover:border-primary-blue' : ''}
|
||||
`}
|
||||
>
|
||||
{race.status === 'running' && (
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-performance-green animate-pulse" />
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<StatusIcon className={`w-5 h-5 ${config.color}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-white truncate">{race.track}</h3>
|
||||
<p className="text-xs text-gray-500">{formatTime(race.scheduledAt)}</p>
|
||||
</div>
|
||||
{relativeTime && (
|
||||
<span className={`text-xs ${config.color}`}>{relativeTime}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function RaceCard({ race, onClick, className }: RaceCardProps) {
|
||||
const config = raceStatusConfig[race.status as keyof typeof raceStatusConfig];
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`
|
||||
relative overflow-hidden p-5 rounded-xl bg-iron-gray border ${config.border}
|
||||
transition-all duration-200
|
||||
${onClick ? 'cursor-pointer hover:scale-[1.02] hover:border-primary-blue' : ''}
|
||||
`}
|
||||
className={`group relative overflow-hidden rounded-xl bg-iron-gray border ${config.border} p-4 cursor-pointer transition-all duration-200 hover:scale-[1.01] hover:border-primary-blue ${className || ''}`}
|
||||
>
|
||||
{/* Live indicator bar */}
|
||||
{/* Live indicator */}
|
||||
{race.status === 'running' && (
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-performance-green via-performance-green/50 to-performance-green animate-pulse" />
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{/* Left side - Race info */}
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Time Column */}
|
||||
<div className="flex-shrink-0 text-center min-w-[60px]">
|
||||
<p className="text-lg font-bold text-white">
|
||||
{formatTime(race.scheduledAt)}
|
||||
</p>
|
||||
<p className={`text-xs ${config.color}`}>
|
||||
{race.status === 'running'
|
||||
? 'LIVE'
|
||||
: getRelativeTime(race.scheduledAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className={`w-px self-stretch ${config.bg}`} />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-lg font-semibold text-white truncate">{race.track}</h3>
|
||||
{/* Status badge */}
|
||||
<div className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full ${config.bg} border ${config.border} flex-shrink-0`}>
|
||||
{race.status === 'running' && (
|
||||
<span className="w-1.5 h-1.5 bg-performance-green rounded-full animate-pulse" />
|
||||
)}
|
||||
<StatusIcon className={`w-3.5 h-3.5 ${config.color}`} />
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-white truncate group-hover:text-primary-blue transition-colors">
|
||||
{race.track}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="flex items-center gap-1 text-sm text-gray-400">
|
||||
<Car className="w-3.5 h-3.5" />
|
||||
{race.car}
|
||||
</span>
|
||||
{race.strengthOfField && (
|
||||
<span className="flex items-center gap-1 text-sm text-gray-400">
|
||||
<Zap className="w-3.5 h-3.5 text-warning-amber" />
|
||||
SOF {race.strengthOfField}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full ${config.bg} ${config.border} border`}>
|
||||
<config.icon className={`w-3.5 h-3.5 ${config.color}`} />
|
||||
<span className={`text-xs font-medium ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-400">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Car className="w-3.5 h-3.5" />
|
||||
{race.car}
|
||||
</span>
|
||||
{race.strengthOfField && (
|
||||
<span className="flex items-center gap-1.5 text-warning-amber">
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
SOF {race.strengthOfField}
|
||||
</span>
|
||||
)}
|
||||
{leagueName && (
|
||||
<span className="flex items-center gap-1.5 text-primary-blue">
|
||||
<Trophy className="w-3.5 h-3.5" />
|
||||
{leagueName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* League Link */}
|
||||
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
|
||||
<Link
|
||||
href={`/leagues/${race.leagueId ?? ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-2 text-sm text-primary-blue hover:underline"
|
||||
>
|
||||
<Trophy className="w-3.5 h-3.5" />
|
||||
{race.leagueName}
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Date/Time */}
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-white font-medium">{formatDate(race.scheduledAt)}</p>
|
||||
<p className="text-gray-500 text-sm">{formatTime(race.scheduledAt)}</p>
|
||||
{relativeTime && (
|
||||
<p className={`text-sm mt-1 ${config.color}`}>{relativeTime}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom row */}
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-charcoal-outline/50">
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wide">
|
||||
{race.sessionType}
|
||||
</span>
|
||||
{race.registeredCount !== undefined && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{race.registeredCount} registered
|
||||
{race.maxParticipants && ` / ${race.maxParticipants}`}
|
||||
</span>
|
||||
)}
|
||||
{/* Arrow */}
|
||||
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-primary-blue transition-colors flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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