page wrapper
This commit is contained in:
316
apps/website/templates/DashboardTemplate.tsx
Normal file
316
apps/website/templates/DashboardTemplate.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Activity,
|
||||
Award,
|
||||
Calendar,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Flag,
|
||||
Medal,
|
||||
Play,
|
||||
Star,
|
||||
Target,
|
||||
Trophy,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { FeedItemRow } from '@/components/dashboard/FeedItemRow';
|
||||
import { FriendItem } from '@/components/dashboard/FriendItem';
|
||||
import { LeagueStandingItem } from '@/components/dashboard/LeagueStandingItem';
|
||||
import { StatCard } from '@/components/dashboard/StatCard';
|
||||
import { UpcomingRaceItem } from '@/components/dashboard/UpcomingRaceItem';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
|
||||
import { getCountryFlag } from '@/lib/utilities/country';
|
||||
import { getGreeting, timeUntil } from '@/lib/utilities/time';
|
||||
|
||||
import { DashboardOverviewViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
|
||||
|
||||
interface DashboardTemplateProps {
|
||||
data: DashboardOverviewViewModel;
|
||||
}
|
||||
|
||||
export function DashboardTemplate({ data }: DashboardTemplateProps) {
|
||||
const currentDriver = data.currentDriver;
|
||||
const nextRace = data.nextRace;
|
||||
const upcomingRaces = data.upcomingRaces;
|
||||
const leagueStandingsSummaries = data.leagueStandings;
|
||||
const feedSummary = { items: data.feedItems };
|
||||
const friends = data.friends;
|
||||
const activeLeaguesCount = data.activeLeaguesCount;
|
||||
|
||||
const { totalRaces, wins, podiums, rating, globalRank, consistency } = currentDriver;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-deep-graphite">
|
||||
{/* Hero Section */}
|
||||
<section className="relative overflow-hidden">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/10 via-deep-graphite to-purple-600/5" />
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
||||
}} />
|
||||
</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">
|
||||
{/* Welcome Message */}
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="relative">
|
||||
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-primary-blue to-purple-600 p-0.5 shadow-xl shadow-primary-blue/20">
|
||||
<div className="w-full h-full rounded-xl overflow-hidden bg-iron-gray">
|
||||
<Image
|
||||
src={currentDriver.avatarUrl}
|
||||
alt={currentDriver.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-performance-green border-3 border-deep-graphite" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm mb-1">{getGreeting()},</p>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2">
|
||||
{currentDriver.name}
|
||||
<span className="ml-3 text-2xl">{getCountryFlag(currentDriver.country)}</span>
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary-blue/10 border border-primary-blue/30">
|
||||
<Star className="w-3.5 h-3.5 text-primary-blue" />
|
||||
<span className="text-sm font-semibold text-primary-blue">{rating}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-yellow-400/10 border border-yellow-400/30">
|
||||
<Trophy className="w-3.5 h-3.5 text-yellow-400" />
|
||||
<span className="text-sm font-semibold text-yellow-400">#{globalRank}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{totalRaces} races completed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href="/leagues">
|
||||
<Button variant="secondary" className="flex items-center gap-2">
|
||||
<Flag className="w-4 h-4" />
|
||||
Browse Leagues
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/profile">
|
||||
<Button variant="primary" className="flex items-center gap-2">
|
||||
<Activity className="w-4 h-4" />
|
||||
View Profile
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats Row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
|
||||
<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>
|
||||
|
||||
{/* Main Content */}
|
||||
<section className="max-w-7xl mx-auto px-6 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Next Race Card */}
|
||||
{nextRace && (
|
||||
<Card className="relative overflow-hidden bg-gradient-to-br from-iron-gray to-iron-gray/80 border-primary-blue/30">
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-bl from-primary-blue/20 to-transparent rounded-bl-full" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-primary-blue/20 border border-primary-blue/30">
|
||||
<Play className="w-3.5 h-3.5 text-primary-blue" />
|
||||
<span className="text-xs font-semibold text-primary-blue uppercase tracking-wider">Next Race</span>
|
||||
</div>
|
||||
{nextRace.isMyLeague && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-performance-green/20 text-performance-green text-xs font-medium">
|
||||
Your League
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">{nextRace.track}</h2>
|
||||
<p className="text-gray-400 mb-3">{nextRace.car}</p>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1.5 text-gray-400">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{nextRace.scheduledAt.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-gray-400">
|
||||
<Clock className="w-4 h-4" />
|
||||
{nextRace.scheduledAt.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-3">
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Starts in</p>
|
||||
<p className="text-3xl font-bold text-primary-blue font-mono">{timeUntil(nextRace.scheduledAt)}</p>
|
||||
</div>
|
||||
<Link href={`/races/${nextRace.id}`}>
|
||||
<Button variant="primary" className="flex items-center gap-2">
|
||||
View Details
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* League Standings Preview */}
|
||||
{leagueStandingsSummaries.length > 0 && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Award className="w-5 h-5 text-yellow-400" />
|
||||
Your Championship Standings
|
||||
</h2>
|
||||
<Link href="/profile/leagues" className="text-sm text-primary-blue hover:underline flex items-center gap-1">
|
||||
View all <ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{leagueStandingsSummaries.map((summary: any) => (
|
||||
<LeagueStandingItem
|
||||
key={summary.leagueId}
|
||||
leagueId={summary.leagueId}
|
||||
leagueName={summary.leagueName}
|
||||
position={summary.position}
|
||||
points={summary.points}
|
||||
totalDrivers={summary.totalDrivers}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Activity Feed */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-cyan-400" />
|
||||
Recent Activity
|
||||
</h2>
|
||||
</div>
|
||||
{feedSummary.items.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{feedSummary.items.slice(0, 5).map((item: any) => (
|
||||
<FeedItemRow key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Activity className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-400 mb-2">No activity yet</p>
|
||||
<p className="text-sm text-gray-500">Join leagues and add friends to see activity here</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Upcoming Races */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-red-400" />
|
||||
Upcoming Races
|
||||
</h3>
|
||||
<Link href="/races" className="text-xs text-primary-blue hover:underline">
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
{upcomingRaces.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{upcomingRaces.slice(0, 5).map((race: any) => (
|
||||
<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>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Friends */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-purple-400" />
|
||||
Friends
|
||||
</h3>
|
||||
<span className="text-xs text-gray-500">{friends.length} friends</span>
|
||||
</div>
|
||||
{friends.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{friends.slice(0, 6).map((friend: any) => (
|
||||
<FriendItem
|
||||
key={friend.id}
|
||||
id={friend.id}
|
||||
name={friend.name}
|
||||
avatarUrl={friend.avatarUrl}
|
||||
country={friend.country}
|
||||
/>
|
||||
))}
|
||||
{friends.length > 6 && (
|
||||
<Link
|
||||
href="/profile"
|
||||
className="block text-center py-2 text-sm text-primary-blue hover:underline"
|
||||
>
|
||||
+{friends.length - 6} more
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6">
|
||||
<UserPlus className="w-10 h-10 text-gray-600 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-400 mb-2">No friends yet</p>
|
||||
<Link href="/drivers">
|
||||
<Button variant="secondary" className="text-xs">
|
||||
Find Drivers
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -18,22 +18,19 @@ import { CategoryDistribution } from '@/components/drivers/CategoryDistribution'
|
||||
import { LeaderboardPreview } from '@/components/drivers/LeaderboardPreview';
|
||||
import { RecentActivity } from '@/components/drivers/RecentActivity';
|
||||
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
||||
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
|
||||
|
||||
interface DriversTemplateProps {
|
||||
drivers: DriverLeaderboardItemViewModel[];
|
||||
totalRaces: number;
|
||||
totalWins: number;
|
||||
activeCount: number;
|
||||
isLoading?: boolean;
|
||||
data: DriverLeaderboardViewModel | null;
|
||||
}
|
||||
|
||||
export function DriversTemplate({
|
||||
drivers,
|
||||
totalRaces,
|
||||
totalWins,
|
||||
activeCount,
|
||||
isLoading = false
|
||||
}: DriversTemplateProps) {
|
||||
export function DriversTemplate({ data }: DriversTemplateProps) {
|
||||
const drivers = data?.drivers || [];
|
||||
const totalRaces = data?.totalRaces || 0;
|
||||
const totalWins = data?.totalWins || 0;
|
||||
const activeCount = data?.activeCount || 0;
|
||||
const isLoading = false;
|
||||
|
||||
const router = useRouter();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
|
||||
355
apps/website/templates/HomeTemplate.tsx
Normal file
355
apps/website/templates/HomeTemplate.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
import Image from 'next/image';
|
||||
import Hero from '@/components/landing/Hero';
|
||||
import AlternatingSection from '@/components/landing/AlternatingSection';
|
||||
import FeatureGrid from '@/components/landing/FeatureGrid';
|
||||
import DiscordCTA from '@/components/landing/DiscordCTA';
|
||||
import FAQ from '@/components/landing/FAQ';
|
||||
import Footer from '@/components/landing/Footer';
|
||||
import CareerProgressionMockup from '@/components/mockups/CareerProgressionMockup';
|
||||
import RaceHistoryMockup from '@/components/mockups/RaceHistoryMockup';
|
||||
import CompanionAutomationMockup from '@/components/mockups/CompanionAutomationMockup';
|
||||
import SimPlatformMockup from '@/components/mockups/SimPlatformMockup';
|
||||
import MockupStack from '@/components/ui/MockupStack';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
|
||||
export interface HomeTemplateData {
|
||||
isAlpha: boolean;
|
||||
upcomingRaces: any[];
|
||||
topLeagues: any[];
|
||||
teams: any[];
|
||||
}
|
||||
|
||||
export interface HomeTemplateProps {
|
||||
data: HomeTemplateData;
|
||||
}
|
||||
|
||||
export default function HomeTemplate({ data }: HomeTemplateProps) {
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<Hero />
|
||||
|
||||
{/* Section 1: A Persistent Identity */}
|
||||
<AlternatingSection
|
||||
heading="A Persistent Identity"
|
||||
backgroundVideo="/gameplay.mp4"
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
Your races, your seasons, your progress — finally in one place.
|
||||
</p>
|
||||
<div className="space-y-3 mt-4">
|
||||
<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)]">
|
||||
<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">
|
||||
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-slate-200 leading-relaxed font-light">
|
||||
Lifetime stats and season history across all your leagues
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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)]">
|
||||
<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">
|
||||
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-slate-200 leading-relaxed font-light">
|
||||
Track your performance, consistency, and team contributions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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)]">
|
||||
<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">
|
||||
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-slate-200 leading-relaxed font-light">
|
||||
Your own rating that reflects real league competition
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4">
|
||||
iRacing gives you physics. GridPilot gives you a career.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
mockup={<CareerProgressionMockup />}
|
||||
layout="text-left"
|
||||
/>
|
||||
|
||||
<FeatureGrid />
|
||||
|
||||
{/* Section 2: Results That Actually Stay */}
|
||||
<AlternatingSection
|
||||
heading="Results That Actually Stay"
|
||||
backgroundImage="/images/ff1600.jpeg"
|
||||
description={
|
||||
<>
|
||||
<p className="text-sm md:text-base leading-relaxed">
|
||||
Every race you run stays with you.
|
||||
</p>
|
||||
<div className="space-y-3 mt-4 md:mt-6">
|
||||
<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-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
|
||||
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="flex items-start gap-2.5 md:gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
|
||||
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
|
||||
Your stats, your team, your story — all connected
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
|
||||
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="flex items-start gap-2.5 md:gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
|
||||
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
|
||||
One race result updates your profile, team points, rating, and season history
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
|
||||
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="flex items-start gap-2.5 md:gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
|
||||
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
|
||||
No more fragmented data across spreadsheets and forums
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
|
||||
Your racing career, finally in one place.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
mockup={<MockupStack index={1}><RaceHistoryMockup /></MockupStack>}
|
||||
layout="text-right"
|
||||
/>
|
||||
|
||||
{/* Section 3: Automatic Session Creation */}
|
||||
<AlternatingSection
|
||||
heading="Automatic Session Creation"
|
||||
description={
|
||||
<>
|
||||
<p className="text-sm md:text-base leading-relaxed">
|
||||
Setting up league races used to mean clicking through iRacing's wizard 20 times.
|
||||
</p>
|
||||
<div className="space-y-3 mt-4 md:mt-6">
|
||||
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
|
||||
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="flex items-start gap-2.5 md:gap-3 relative">
|
||||
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
|
||||
<span className="text-primary-blue font-bold text-sm">1</span>
|
||||
</div>
|
||||
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
|
||||
Our companion app syncs with your league schedule
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
|
||||
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="flex items-start gap-2.5 md:gap-3 relative">
|
||||
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
|
||||
<span className="text-primary-blue font-bold text-sm">2</span>
|
||||
</div>
|
||||
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
|
||||
When it's race time, it creates the iRacing session automatically
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
|
||||
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="flex items-start gap-2.5 md:gap-3 relative">
|
||||
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
|
||||
<span className="text-primary-blue font-bold text-sm">3</span>
|
||||
</div>
|
||||
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
|
||||
No clicking through wizards. No manual setup
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
|
||||
Automation instead of repetition.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
mockup={<CompanionAutomationMockup />}
|
||||
layout="text-left"
|
||||
/>
|
||||
|
||||
{/* Section 4: Game-Agnostic Platform */}
|
||||
<AlternatingSection
|
||||
heading="Built for iRacing. Ready for the future."
|
||||
backgroundImage="/images/lmp3.jpeg"
|
||||
description={
|
||||
<>
|
||||
<p className="text-sm md:text-base leading-relaxed">
|
||||
Right now, we're focused on making iRacing league racing better.
|
||||
</p>
|
||||
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
|
||||
But sims come and go. Your leagues, your teams, your rating — those stay.
|
||||
</p>
|
||||
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
|
||||
GridPilot is built to outlast any single platform.
|
||||
</p>
|
||||
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
|
||||
When the next sim arrives, your competitive identity moves with you.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
mockup={<SimPlatformMockup />}
|
||||
layout="text-right"
|
||||
/>
|
||||
|
||||
{/* Alpha-only discovery section */}
|
||||
{data.isAlpha && (
|
||||
<section className="max-w-7xl mx-auto mt-20 mb-20 px-6">
|
||||
<div className="flex items-baseline justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-white">Discover the grid</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Explore leagues, teams, and races that make up the GridPilot ecosystem.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-3">
|
||||
{/* Top leagues */}
|
||||
<Card className="bg-iron-gray/80">
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-white">Featured leagues</h3>
|
||||
<Button
|
||||
as="a"
|
||||
href="/leagues"
|
||||
variant="secondary"
|
||||
className="text-[11px] px-3 py-1.5"
|
||||
>
|
||||
View all
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{data.topLeagues.slice(0, 4).map((league: any) => (
|
||||
<li key={league.id} className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-md bg-primary-blue/15 border border-primary-blue/30 flex items-center justify-center text-xs font-semibold text-primary-blue">
|
||||
{league.name
|
||||
.split(' ')
|
||||
.map((word: string) => word[0])
|
||||
.join('')
|
||||
.slice(0, 3)
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white truncate">{league.name}</p>
|
||||
<p className="text-xs text-gray-400 line-clamp-2">
|
||||
{league.description}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
{/* Teams */}
|
||||
<Card className="bg-iron-gray/80">
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-white">Teams on the grid</h3>
|
||||
<Button
|
||||
as="a"
|
||||
href="/teams"
|
||||
variant="secondary"
|
||||
className="text-[11px] px-3 py-1.5"
|
||||
>
|
||||
Browse teams
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{data.teams.slice(0, 4).map(team => (
|
||||
<li key={team.id} className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-md bg-charcoal-outline flex items-center justify-center overflow-hidden border border-charcoal-outline">
|
||||
<Image
|
||||
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
|
||||
alt={team.name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white truncate">{team.name}</p>
|
||||
<p className="text-xs text-gray-400 line-clamp-2">
|
||||
{team.description}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
{/* Upcoming races */}
|
||||
<Card className="bg-iron-gray/80">
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-white">Upcoming races</h3>
|
||||
<Button
|
||||
as="a"
|
||||
href="/races"
|
||||
variant="secondary"
|
||||
className="text-[11px] px-3 py-1.5"
|
||||
>
|
||||
View schedule
|
||||
</Button>
|
||||
</div>
|
||||
{data.upcomingRaces.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">
|
||||
No races scheduled in this demo snapshot.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3 text-sm">
|
||||
{data.upcomingRaces.map(race => (
|
||||
<li key={race.id} className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white truncate">{race.track}</p>
|
||||
<p className="text-xs text-gray-400 truncate">{race.car}</p>
|
||||
</div>
|
||||
<div className="text-right text-xs text-gray-500 whitespace-nowrap">
|
||||
{race.formattedDate}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<DiscordCTA />
|
||||
<FAQ />
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
234
apps/website/templates/LeagueAdminScheduleTemplate.tsx
Normal file
234
apps/website/templates/LeagueAdminScheduleTemplate.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { LeagueAdminScheduleViewModel } from '@/lib/view-models/LeagueAdminScheduleViewModel';
|
||||
import type { LeagueSeasonSummaryViewModel } from '@/lib/view-models/LeagueSeasonSummaryViewModel';
|
||||
import Card from '@/components/ui/Card';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
interface LeagueAdminScheduleTemplateProps {
|
||||
data: {
|
||||
schedule: LeagueAdminScheduleViewModel;
|
||||
seasons: LeagueSeasonSummaryViewModel[];
|
||||
seasonId: string;
|
||||
};
|
||||
onSeasonChange: (seasonId: string) => void;
|
||||
onPublishToggle: () => void;
|
||||
onAddOrSave: () => void;
|
||||
onEdit: (raceId: string) => void;
|
||||
onDelete: (raceId: string) => void;
|
||||
onCancelEdit: () => void;
|
||||
|
||||
// Form state
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAtIso: string;
|
||||
editingRaceId: string | null;
|
||||
|
||||
// Mutation states
|
||||
isPublishing: boolean;
|
||||
isSaving: boolean;
|
||||
isDeleting: string | null;
|
||||
|
||||
// Form setters
|
||||
setTrack: (value: string) => void;
|
||||
setCar: (value: string) => void;
|
||||
setScheduledAtIso: (value: string) => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN TEMPLATE COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function LeagueAdminScheduleTemplate({
|
||||
data,
|
||||
onSeasonChange,
|
||||
onPublishToggle,
|
||||
onAddOrSave,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCancelEdit,
|
||||
track,
|
||||
car,
|
||||
scheduledAtIso,
|
||||
editingRaceId,
|
||||
isPublishing,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
setTrack,
|
||||
setCar,
|
||||
setScheduledAtIso,
|
||||
}: LeagueAdminScheduleTemplateProps) {
|
||||
const { schedule, seasons, seasonId } = data;
|
||||
|
||||
const isEditing = editingRaceId !== null;
|
||||
const publishedLabel = schedule.published ? 'Published' : 'Unpublished';
|
||||
|
||||
const selectedSeasonLabel = useMemo(() => {
|
||||
const selected = seasons.find((s) => s.seasonId === seasonId);
|
||||
return selected?.name ?? seasonId;
|
||||
}, [seasons, seasonId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Schedule Admin</h1>
|
||||
<p className="text-sm text-gray-400">Create, edit, and publish season races.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm text-gray-300" htmlFor="seasonId">
|
||||
Season
|
||||
</label>
|
||||
{seasons.length > 0 ? (
|
||||
<select
|
||||
id="seasonId"
|
||||
value={seasonId}
|
||||
onChange={(e) => onSeasonChange(e.target.value)}
|
||||
className="bg-iron-gray text-white px-3 py-2 rounded"
|
||||
>
|
||||
{seasons.map((s) => (
|
||||
<option key={s.seasonId} value={s.seasonId}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
id="seasonId"
|
||||
value={seasonId}
|
||||
onChange={(e) => onSeasonChange(e.target.value)}
|
||||
className="bg-iron-gray text-white px-3 py-2 rounded"
|
||||
placeholder="season-id"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">Selected: {selectedSeasonLabel}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-gray-300">
|
||||
Status: <span className="font-medium text-white">{publishedLabel}</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPublishToggle}
|
||||
disabled={!schedule || isPublishing}
|
||||
className="px-3 py-1.5 rounded bg-primary-blue text-white disabled:opacity-50"
|
||||
>
|
||||
{isPublishing ? 'Processing...' : (schedule?.published ? 'Unpublish' : 'Publish')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-charcoal-outline pt-4 space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">{isEditing ? 'Edit race' : 'Add race'}</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="track" className="text-sm text-gray-300">
|
||||
Track
|
||||
</label>
|
||||
<input
|
||||
id="track"
|
||||
value={track}
|
||||
onChange={(e) => setTrack(e.target.value)}
|
||||
className="bg-iron-gray text-white px-3 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="car" className="text-sm text-gray-300">
|
||||
Car
|
||||
</label>
|
||||
<input
|
||||
id="car"
|
||||
value={car}
|
||||
onChange={(e) => setCar(e.target.value)}
|
||||
className="bg-iron-gray text-white px-3 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="scheduledAtIso" className="text-sm text-gray-300">
|
||||
Scheduled At (ISO)
|
||||
</label>
|
||||
<input
|
||||
id="scheduledAtIso"
|
||||
value={scheduledAtIso}
|
||||
onChange={(e) => setScheduledAtIso(e.target.value)}
|
||||
className="bg-iron-gray text-white px-3 py-2 rounded"
|
||||
placeholder="2025-01-01T12:00:00.000Z"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddOrSave}
|
||||
disabled={isSaving}
|
||||
className="px-3 py-1.5 rounded bg-primary-blue text-white"
|
||||
>
|
||||
{isSaving ? 'Processing...' : (isEditing ? 'Save' : 'Add race')}
|
||||
</button>
|
||||
|
||||
{isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelEdit}
|
||||
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-charcoal-outline pt-4 space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">Races</h2>
|
||||
|
||||
{schedule?.races.length ? (
|
||||
<div className="space-y-2">
|
||||
{schedule.races.map((race) => (
|
||||
<div
|
||||
key={race.id}
|
||||
className="flex items-center justify-between gap-3 bg-deep-graphite border border-charcoal-outline rounded p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-white font-medium truncate">{race.name}</p>
|
||||
<p className="text-xs text-gray-400 truncate">{race.scheduledAt.toISOString()}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(race.id)}
|
||||
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(race.id)}
|
||||
disabled={isDeleting === race.id}
|
||||
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
|
||||
>
|
||||
{isDeleting === race.id ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-sm text-gray-500">No races yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import LeagueSchedule from '@/components/leagues/LeagueSchedule';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { LeagueScheduleViewModel, LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { EmptyState } from '@/components/shared/state/EmptyState';
|
||||
import { Calendar } from 'lucide-react';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { useRegisterForRace } from '@/hooks/race/useRegisterForRace';
|
||||
import { useWithdrawFromRace } from '@/hooks/race/useWithdrawFromRace';
|
||||
import Card from '@/components/ui/Card';
|
||||
|
||||
// ============================================================================
|
||||
@@ -8,8 +16,8 @@ import Card from '@/components/ui/Card';
|
||||
// ============================================================================
|
||||
|
||||
interface LeagueScheduleTemplateProps {
|
||||
data: LeagueScheduleViewModel;
|
||||
leagueId: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -17,22 +25,224 @@ interface LeagueScheduleTemplateProps {
|
||||
// ============================================================================
|
||||
|
||||
export function LeagueScheduleTemplate({
|
||||
data,
|
||||
leagueId,
|
||||
loading = false,
|
||||
}: LeagueScheduleTemplateProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center text-gray-400">
|
||||
Loading schedule...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const router = useRouter();
|
||||
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming');
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const registerMutation = useRegisterForRace();
|
||||
const withdrawMutation = useWithdrawFromRace();
|
||||
|
||||
const races = useMemo(() => {
|
||||
return data?.races ?? [];
|
||||
}, [data]);
|
||||
|
||||
const upcomingRaces = races.filter((race) => race.isUpcoming);
|
||||
const pastRaces = races.filter((race) => race.isPast);
|
||||
|
||||
const getDisplayRaces = () => {
|
||||
switch (filter) {
|
||||
case 'upcoming':
|
||||
return upcomingRaces;
|
||||
case 'past':
|
||||
return [...pastRaces].reverse();
|
||||
case 'all':
|
||||
return [...upcomingRaces, ...[...pastRaces].reverse()];
|
||||
default:
|
||||
return races;
|
||||
}
|
||||
};
|
||||
|
||||
const displayRaces = getDisplayRaces();
|
||||
|
||||
const handleRegister = async (race: LeagueScheduleRaceViewModel, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const confirmed = window.confirm(`Register for ${race.track ?? race.name}?`);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
if (!currentDriverId) {
|
||||
alert('You must be logged in to register for races');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await registerMutation.mutateAsync({ raceId: race.id, leagueId, driverId: currentDriverId });
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to register');
|
||||
}
|
||||
};
|
||||
|
||||
const handleWithdraw = async (race: LeagueScheduleRaceViewModel, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const confirmed = window.confirm('Withdraw from this race?');
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
if (!currentDriverId) {
|
||||
alert('You must be logged in to withdraw from races');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await withdrawMutation.mutateAsync({ raceId: race.id, driverId: currentDriverId });
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to withdraw');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Schedule</h2>
|
||||
<LeagueSchedule leagueId={leagueId} />
|
||||
|
||||
{/* Filter Controls */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-400">
|
||||
{displayRaces.length} {displayRaces.length === 1 ? 'race' : 'races'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilter('upcoming')}
|
||||
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
|
||||
filter === 'upcoming'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'bg-iron-gray text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Upcoming ({upcomingRaces.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('past')}
|
||||
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
|
||||
filter === 'past'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'bg-iron-gray text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Past ({pastRaces.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
|
||||
filter === 'all'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'bg-iron-gray text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
All ({races.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Race List */}
|
||||
{displayRaces.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<p className="mb-2">No {filter} races</p>
|
||||
{filter === 'upcoming' && (
|
||||
<p className="text-sm text-gray-500">Schedule your first race to get started</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{displayRaces.map((race) => {
|
||||
const isPast = race.isPast;
|
||||
const isUpcoming = race.isUpcoming;
|
||||
const isRegistered = Boolean(race.isRegistered);
|
||||
const trackLabel = race.track ?? race.name;
|
||||
const carLabel = race.car ?? '—';
|
||||
const sessionTypeLabel = (race.sessionType ?? 'race').toLowerCase();
|
||||
const isProcessing =
|
||||
registerMutation.isPending || withdrawMutation.isPending;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={race.id}
|
||||
className={`p-4 rounded-lg border transition-all duration-200 cursor-pointer hover:scale-[1.02] ${
|
||||
isPast
|
||||
? 'bg-iron-gray/50 border-charcoal-outline/50 opacity-75'
|
||||
: 'bg-deep-graphite border-charcoal-outline hover:border-primary-blue'
|
||||
}`}
|
||||
onClick={() => router.push(`/races/${race.id}`)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<h3 className="text-white font-medium">{trackLabel}</h3>
|
||||
{isUpcoming && !isRegistered && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-primary-blue/10 text-primary-blue rounded border border-primary-blue/30">
|
||||
Upcoming
|
||||
</span>
|
||||
)}
|
||||
{isUpcoming && isRegistered && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-green-500/10 text-green-400 rounded border border-green-500/30">
|
||||
✓ Registered
|
||||
</span>
|
||||
)}
|
||||
{isPast && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-700/50 text-gray-400 rounded border border-gray-600/50">
|
||||
Completed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{carLabel}</p>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<p className="text-xs text-gray-500 uppercase">{sessionTypeLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<p className="text-white font-medium">
|
||||
{race.scheduledAt.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{race.scheduledAt.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
{isPast && race.status === 'completed' && (
|
||||
<p className="text-xs text-primary-blue mt-1">View Results →</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Registration Actions */}
|
||||
{isUpcoming && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
{!isRegistered ? (
|
||||
<button
|
||||
onClick={(e) => handleRegister(race, e)}
|
||||
disabled={isProcessing}
|
||||
className="px-3 py-1.5 text-sm font-medium bg-primary-blue hover:bg-primary-blue/80 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
>
|
||||
{registerMutation.isPending ? 'Registering...' : 'Register'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => handleWithdraw(race, e)}
|
||||
disabled={isProcessing}
|
||||
className="px-3 py-1.5 text-sm font-medium bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
>
|
||||
{withdrawMutation.isPending ? 'Withdrawing...' : 'Withdraw'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -58,18 +58,16 @@ interface LeagueSliderProps {
|
||||
icon: React.ElementType;
|
||||
description: string;
|
||||
leagues: LeagueSummaryViewModel[];
|
||||
onLeagueClick: (id: string) => void;
|
||||
autoScroll?: boolean;
|
||||
iconColor?: string;
|
||||
scrollSpeedMultiplier?: number;
|
||||
scrollDirection?: 'left' | 'right';
|
||||
}
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
interface LeaguesTemplateProps {
|
||||
leagues: LeagueSummaryViewModel[];
|
||||
loading?: boolean;
|
||||
onLeagueClick: (id: string) => void;
|
||||
onCreateLeagueClick: () => void;
|
||||
data: LeagueSummaryViewModel[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -183,7 +181,6 @@ function LeagueSlider({
|
||||
icon: Icon,
|
||||
description,
|
||||
leagues,
|
||||
onLeagueClick,
|
||||
autoScroll = true,
|
||||
iconColor = 'text-primary-blue',
|
||||
scrollSpeedMultiplier = 1,
|
||||
@@ -372,7 +369,9 @@ function LeagueSlider({
|
||||
`}</style>
|
||||
{leagues.map((league) => (
|
||||
<div key={league.id} className="flex-shrink-0 w-[320px] h-full">
|
||||
<LeagueCard league={league} onClick={() => onLeagueClick(league.id)} />
|
||||
<Link href={`/leagues/${league.id}`} className="block h-full">
|
||||
<LeagueCard league={league} />
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -386,17 +385,14 @@ function LeagueSlider({
|
||||
// ============================================================================
|
||||
|
||||
export function LeaguesTemplate({
|
||||
leagues,
|
||||
loading = false,
|
||||
onLeagueClick,
|
||||
onCreateLeagueClick,
|
||||
data,
|
||||
}: LeaguesTemplateProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryId>('all');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Filter by search query
|
||||
const searchFilteredLeagues = leagues.filter((league) => {
|
||||
const searchFilteredLeagues = data.filter((league: LeagueSummaryViewModel) => {
|
||||
if (!searchQuery) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
@@ -416,7 +412,7 @@ export function LeaguesTemplate({
|
||||
const leaguesByCategory = CATEGORIES.reduce(
|
||||
(acc, category) => {
|
||||
// First try to use the dedicated category field, fall back to scoring-based filtering
|
||||
acc[category.id] = searchFilteredLeagues.filter((league) => {
|
||||
acc[category.id] = searchFilteredLeagues.filter((league: LeagueSummaryViewModel) => {
|
||||
// If league has a category field, use it directly
|
||||
if (league.category) {
|
||||
return league.category === category.id;
|
||||
@@ -440,19 +436,6 @@ export function LeaguesTemplate({
|
||||
{ id: 'sprint', speed: 1.2, direction: 'right' },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<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">Loading leagues...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 pb-12">
|
||||
{/* Hero Section */}
|
||||
@@ -480,7 +463,7 @@ export function LeaguesTemplate({
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-performance-green animate-pulse" />
|
||||
<span className="text-sm text-gray-400">
|
||||
<span className="text-white font-semibold">{leagues.length}</span> active leagues
|
||||
<span className="text-white font-semibold">{data.length}</span> active leagues
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -500,14 +483,10 @@ export function LeaguesTemplate({
|
||||
|
||||
{/* CTA */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onCreateLeagueClick}
|
||||
className="flex items-center gap-2 px-6 py-3"
|
||||
>
|
||||
<Link href="/leagues/create" className="flex items-center gap-2 px-6 py-3 bg-primary-blue text-white rounded-lg hover:bg-blue-600 transition-colors">
|
||||
<Plus className="w-5 h-5" />
|
||||
<span>Create League</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<p className="text-xs text-gray-500 text-center">Set up your own racing series</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -574,7 +553,7 @@ export function LeaguesTemplate({
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{leagues.length === 0 ? (
|
||||
{data.length === 0 ? (
|
||||
/* Empty State */
|
||||
<Card className="text-center py-16">
|
||||
<div className="max-w-md mx-auto">
|
||||
@@ -587,14 +566,10 @@ export function LeaguesTemplate({
|
||||
<p className="text-gray-400 mb-8">
|
||||
Be the first to create a racing series. Start your own league and invite drivers to compete for glory.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onCreateLeagueClick}
|
||||
className="flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Link href="/leagues/create" className="inline-flex items-center gap-2 px-6 py-3 bg-primary-blue text-white rounded-lg hover:bg-blue-600 transition-colors">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Create Your First League
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
) : activeCategory === 'all' && !searchQuery ? (
|
||||
@@ -613,7 +588,6 @@ export function LeaguesTemplate({
|
||||
icon={category.icon}
|
||||
description={category.description}
|
||||
leagues={leaguesByCategory[category.id]}
|
||||
onLeagueClick={onLeagueClick}
|
||||
autoScroll={true}
|
||||
iconColor={category.color || 'text-primary-blue'}
|
||||
scrollSpeedMultiplier={speed}
|
||||
@@ -640,7 +614,9 @@ export function LeaguesTemplate({
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{categoryFilteredLeagues.map((league) => (
|
||||
<LeagueCard key={league.id} league={league} onClick={() => onLeagueClick(league.id)} />
|
||||
<Link key={league.id} href={`/leagues/${league.id}`} className="block h-full">
|
||||
<LeagueCard league={league} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
|
||||
578
apps/website/templates/SponsorLeagueDetailTemplate.tsx
Normal file
578
apps/website/templates/SponsorLeagueDetailTemplate.tsx
Normal file
@@ -0,0 +1,578 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { siteConfig } from '@/lib/siteConfig';
|
||||
import {
|
||||
Trophy,
|
||||
Users,
|
||||
Calendar,
|
||||
Eye,
|
||||
TrendingUp,
|
||||
Download,
|
||||
Image as ImageIcon,
|
||||
ExternalLink,
|
||||
ChevronRight,
|
||||
Star,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
Flag,
|
||||
Car,
|
||||
BarChart3,
|
||||
ArrowUpRight,
|
||||
Megaphone,
|
||||
CreditCard,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SponsorLeagueDetailData {
|
||||
league: {
|
||||
id: string;
|
||||
name: string;
|
||||
game: string;
|
||||
season: string;
|
||||
description: string;
|
||||
tier: 'premium' | 'standard' | 'starter';
|
||||
rating: number;
|
||||
drivers: number;
|
||||
races: number;
|
||||
completedRaces: number;
|
||||
racesLeft: number;
|
||||
engagement: number;
|
||||
totalImpressions: number;
|
||||
formattedTotalImpressions: string;
|
||||
projectedTotal: number;
|
||||
formattedProjectedTotal: string;
|
||||
mainSponsorCpm: number;
|
||||
formattedMainSponsorCpm: string;
|
||||
avgViewsPerRace: number;
|
||||
formattedAvgViewsPerRace: string;
|
||||
nextRace?: {
|
||||
name: string;
|
||||
date: string;
|
||||
};
|
||||
sponsorSlots: {
|
||||
main: {
|
||||
available: boolean;
|
||||
price: number;
|
||||
benefits: string[];
|
||||
};
|
||||
secondary: {
|
||||
available: number;
|
||||
total: number;
|
||||
price: number;
|
||||
benefits: string[];
|
||||
};
|
||||
};
|
||||
tierConfig: {
|
||||
bgColor: string;
|
||||
color: string;
|
||||
border: string;
|
||||
};
|
||||
};
|
||||
drivers: Array<{
|
||||
id: string;
|
||||
position: number;
|
||||
name: string;
|
||||
team: string;
|
||||
country: string;
|
||||
races: number;
|
||||
impressions: number;
|
||||
formattedImpressions: string;
|
||||
}>;
|
||||
races: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
date: string;
|
||||
formattedDate: string;
|
||||
status: 'completed' | 'upcoming';
|
||||
views: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface SponsorLeagueDetailTemplateProps {
|
||||
data: SponsorLeagueDetailData;
|
||||
}
|
||||
|
||||
type TabType = 'overview' | 'drivers' | 'races' | 'sponsor';
|
||||
|
||||
export function SponsorLeagueDetailTemplate({ data }: SponsorLeagueDetailTemplateProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||
const [selectedTier, setSelectedTier] = useState<'main' | 'secondary'>('main');
|
||||
|
||||
const league = data.league;
|
||||
const config = league.tierConfig;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-8 px-4">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 mb-6">
|
||||
<Link href="/sponsor/dashboard" className="hover:text-white transition-colors">Dashboard</Link>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<Link href="/sponsor/leagues" className="hover:text-white transition-colors">Leagues</Link>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<span className="text-white">{league.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-start justify-between gap-6 mb-8">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium capitalize ${config.bgColor} ${config.color} border ${config.border}`}>
|
||||
⭐ {league.tier}
|
||||
</span>
|
||||
<span className="px-3 py-1 rounded-full text-sm font-medium bg-performance-green/10 text-performance-green">
|
||||
Active Season
|
||||
</span>
|
||||
<div className="flex items-center gap-1 px-2 py-1 rounded bg-iron-gray/50">
|
||||
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
|
||||
<span className="text-sm font-medium text-white">{league.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">{league.name}</h1>
|
||||
<p className="text-gray-400 mb-4">{league.game} • {league.season} • {league.completedRaces}/{league.races} races completed</p>
|
||||
<p className="text-gray-400 max-w-2xl">{league.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<Link href={`/leagues/${league.id}`}>
|
||||
<Button variant="secondary">
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
View League
|
||||
</Button>
|
||||
</Link>
|
||||
{(league.sponsorSlots.main.available || league.sponsorSlots.secondary.available > 0) && (
|
||||
<Button variant="primary" onClick={() => setActiveTab('sponsor')}>
|
||||
<Megaphone className="w-4 h-4 mr-2" />
|
||||
Become a Sponsor
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10">
|
||||
<Eye className="w-5 h-5 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-white">{league.formattedTotalImpressions}</div>
|
||||
<div className="text-xs text-gray-400">Total Views</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10">
|
||||
<TrendingUp className="w-5 h-5 text-performance-green" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-white">{league.formattedAvgViewsPerRace}</div>
|
||||
<div className="text-xs text-gray-400">Avg/Race</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10">
|
||||
<Users className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-white">{league.drivers}</div>
|
||||
<div className="text-xs text-gray-400">Drivers</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/10">
|
||||
<BarChart3 className="w-5 h-5 text-warning-amber" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-white">{league.engagement}%</div>
|
||||
<div className="text-xs text-gray-400">Engagement</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-racing-red/10">
|
||||
<Calendar className="w-5 h-5 text-racing-red" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-white">{league.racesLeft}</div>
|
||||
<div className="text-xs text-gray-400">Races Left</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-6 border-b border-charcoal-outline overflow-x-auto">
|
||||
{(['overview', 'drivers', 'races', 'sponsor'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-3 text-sm font-medium capitalize transition-colors border-b-2 -mb-px whitespace-nowrap ${
|
||||
activeTab === tab
|
||||
? 'text-primary-blue border-primary-blue'
|
||||
: 'text-gray-400 border-transparent hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{tab === 'sponsor' ? '🎯 Become a Sponsor' : tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="p-5">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Trophy className="w-5 h-5 text-primary-blue" />
|
||||
League Information
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-400">Platform</span>
|
||||
<span className="text-white font-medium">{league.game}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-400">Season</span>
|
||||
<span className="text-white font-medium">{league.season}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-400">Duration</span>
|
||||
<span className="text-white font-medium">Oct 2025 - Feb 2026</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-400">Drivers</span>
|
||||
<span className="text-white font-medium">{league.drivers}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-gray-400">Races</span>
|
||||
<span className="text-white font-medium">{league.races}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-5">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-performance-green" />
|
||||
Sponsorship Value
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-400">Total Season Views</span>
|
||||
<span className="text-white font-medium">{league.formattedTotalImpressions}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-400">Projected Total</span>
|
||||
<span className="text-white font-medium">{league.formattedProjectedTotal}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-400">Main Sponsor CPM</span>
|
||||
<span className="text-performance-green font-medium">
|
||||
{league.formattedMainSponsorCpm}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-400">Engagement Rate</span>
|
||||
<span className="text-white font-medium">{league.engagement}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-gray-400">League Rating</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
|
||||
<span className="text-white font-medium">{league.rating}/5.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Next Race */}
|
||||
{league.nextRace && (
|
||||
<Card className="p-5 lg:col-span-2">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Flag className="w-5 h-5 text-warning-amber" />
|
||||
Next Race
|
||||
</h3>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-warning-amber/20 flex items-center justify-center">
|
||||
<Flag className="w-6 h-6 text-warning-amber" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-white text-lg">{league.nextRace.name}</p>
|
||||
<p className="text-sm text-gray-400">{league.nextRace.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary">
|
||||
View Schedule
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'drivers' && (
|
||||
<Card>
|
||||
<div className="p-4 border-b border-charcoal-outline">
|
||||
<h3 className="text-lg font-semibold text-white">Championship Standings</h3>
|
||||
<p className="text-sm text-gray-400">Top drivers carrying sponsor branding</p>
|
||||
</div>
|
||||
<div className="divide-y divide-charcoal-outline/50">
|
||||
{data.drivers.map((driver) => (
|
||||
<div key={driver.id} className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center text-lg font-bold text-white">
|
||||
{driver.position}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-white">{driver.name}</div>
|
||||
<div className="text-sm text-gray-500">{driver.team} • {driver.country}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-right">
|
||||
<div className="font-medium text-white">{driver.races}</div>
|
||||
<div className="text-xs text-gray-500">races</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-white">{driver.formattedImpressions}</div>
|
||||
<div className="text-xs text-gray-500">views</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'races' && (
|
||||
<Card>
|
||||
<div className="p-4 border-b border-charcoal-outline">
|
||||
<h3 className="text-lg font-semibold text-white">Race Calendar</h3>
|
||||
<p className="text-sm text-gray-400">Season schedule with view statistics</p>
|
||||
</div>
|
||||
<div className="divide-y divide-charcoal-outline/50">
|
||||
{data.races.map((race) => (
|
||||
<div key={race.id} className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-3 h-3 rounded-full ${
|
||||
race.status === 'completed' ? 'bg-performance-green' : 'bg-warning-amber'
|
||||
}`} />
|
||||
<div>
|
||||
<div className="font-medium text-white">{race.name}</div>
|
||||
<div className="text-sm text-gray-500">{race.formattedDate}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{race.status === 'completed' ? (
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-white">{race.views.toLocaleString()}</div>
|
||||
<div className="text-xs text-gray-500">views</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="px-3 py-1 rounded-full text-xs font-medium bg-warning-amber/20 text-warning-amber">
|
||||
Upcoming
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'sponsor' && (
|
||||
<div className="space-y-6">
|
||||
{/* Tier Selection */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Main Sponsor */}
|
||||
<Card
|
||||
className={`p-5 cursor-pointer transition-all ${
|
||||
selectedTier === 'main'
|
||||
? 'border-primary-blue ring-2 ring-primary-blue/20'
|
||||
: 'hover:border-charcoal-outline/80'
|
||||
} ${!league.sponsorSlots.main.available ? 'opacity-60' : ''}`}
|
||||
onClick={() => league.sponsorSlots.main.available && setSelectedTier('main')}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Trophy className="w-5 h-5 text-yellow-400" />
|
||||
<h3 className="text-lg font-semibold text-white">Main Sponsor</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">Primary branding position</p>
|
||||
</div>
|
||||
{league.sponsorSlots.main.available ? (
|
||||
<span className="px-2 py-1 rounded text-xs font-medium bg-performance-green/20 text-performance-green">
|
||||
Available
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 rounded text-xs font-medium bg-gray-500/20 text-gray-400">
|
||||
Filled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-3xl font-bold text-white mb-4">
|
||||
${league.sponsorSlots.main.price}
|
||||
<span className="text-sm font-normal text-gray-500">/season</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2 mb-4">
|
||||
{league.sponsorSlots.main.benefits.map((benefit: string, i: number) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<CheckCircle2 className="w-4 h-4 text-performance-green flex-shrink-0" />
|
||||
{benefit}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{selectedTier === 'main' && league.sponsorSlots.main.available && (
|
||||
<div className="w-4 h-4 rounded-full bg-primary-blue absolute top-4 right-4 flex items-center justify-center">
|
||||
<CheckCircle2 className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Secondary Sponsor */}
|
||||
<Card
|
||||
className={`p-5 cursor-pointer transition-all ${
|
||||
selectedTier === 'secondary'
|
||||
? 'border-primary-blue ring-2 ring-primary-blue/20'
|
||||
: 'hover:border-charcoal-outline/80'
|
||||
} ${league.sponsorSlots.secondary.available === 0 ? 'opacity-60' : ''}`}
|
||||
onClick={() => league.sponsorSlots.secondary.available > 0 && setSelectedTier('secondary')}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Star className="w-5 h-5 text-purple-400" />
|
||||
<h3 className="text-lg font-semibold text-white">Secondary Sponsor</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">Supporting branding position</p>
|
||||
</div>
|
||||
{league.sponsorSlots.secondary.available > 0 ? (
|
||||
<span className="px-2 py-1 rounded text-xs font-medium bg-performance-green/20 text-performance-green">
|
||||
{league.sponsorSlots.secondary.available}/{league.sponsorSlots.secondary.total} Available
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 rounded text-xs font-medium bg-gray-500/20 text-gray-400">
|
||||
Full
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-3xl font-bold text-white mb-4">
|
||||
${league.sponsorSlots.secondary.price}
|
||||
<span className="text-sm font-normal text-gray-500">/season</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2 mb-4">
|
||||
{league.sponsorSlots.secondary.benefits.map((benefit: string, i: number) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<CheckCircle2 className="w-4 h-4 text-performance-green flex-shrink-0" />
|
||||
{benefit}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{selectedTier === 'secondary' && league.sponsorSlots.secondary.available > 0 && (
|
||||
<div className="w-4 h-4 rounded-full bg-primary-blue absolute top-4 right-4 flex items-center justify-center">
|
||||
<CheckCircle2 className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Checkout Summary */}
|
||||
<Card className="p-5">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<CreditCard className="w-5 h-5 text-primary-blue" />
|
||||
Sponsorship Summary
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-gray-400">Selected Tier</span>
|
||||
<span className="text-white font-medium capitalize">{selectedTier} Sponsor</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-gray-400">Season Price</span>
|
||||
<span className="text-white font-medium">
|
||||
${selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-gray-400">Platform Fee ({siteConfig.fees.platformFeePercent}%)</span>
|
||||
<span className="text-white font-medium">
|
||||
${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * siteConfig.fees.platformFeePercent / 100).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-t border-charcoal-outline pt-4">
|
||||
<span className="text-white font-semibold">Total (excl. VAT)</span>
|
||||
<span className="text-white font-bold text-xl">
|
||||
${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * (1 + siteConfig.fees.platformFeePercent / 100)).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
{siteConfig.vat.notice}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="primary" className="flex-1">
|
||||
<Megaphone className="w-4 h-4 mr-2" />
|
||||
Request Sponsorship
|
||||
</Button>
|
||||
<Button variant="secondary">
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Download Info Pack
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
426
apps/website/templates/SponsorLeaguesTemplate.tsx
Normal file
426
apps/website/templates/SponsorLeaguesTemplate.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { siteConfig } from '@/lib/siteConfig';
|
||||
import {
|
||||
Trophy,
|
||||
Users,
|
||||
Eye,
|
||||
Search,
|
||||
Star,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
Car,
|
||||
Flag,
|
||||
TrendingUp,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Megaphone,
|
||||
ArrowUpDown
|
||||
} from 'lucide-react';
|
||||
|
||||
interface AvailableLeague {
|
||||
id: string;
|
||||
name: string;
|
||||
game: string;
|
||||
drivers: number;
|
||||
avgViewsPerRace: number;
|
||||
mainSponsorSlot: { available: boolean; price: number };
|
||||
secondarySlots: { available: number; total: number; price: number };
|
||||
rating: number;
|
||||
tier: 'premium' | 'standard' | 'starter';
|
||||
nextRace?: string;
|
||||
seasonStatus: 'active' | 'upcoming' | 'completed';
|
||||
description: string;
|
||||
formattedAvgViews: string;
|
||||
formattedCpm: string;
|
||||
cpm: number;
|
||||
tierConfig: any;
|
||||
statusConfig: any;
|
||||
}
|
||||
|
||||
type SortOption = 'rating' | 'drivers' | 'price' | 'views';
|
||||
type TierFilter = 'all' | 'premium' | 'standard' | 'starter';
|
||||
type AvailabilityFilter = 'all' | 'main' | 'secondary';
|
||||
|
||||
interface SponsorLeaguesTemplateProps {
|
||||
data: {
|
||||
leagues: AvailableLeague[];
|
||||
stats: {
|
||||
total: number;
|
||||
mainAvailable: number;
|
||||
secondaryAvailable: number;
|
||||
totalDrivers: number;
|
||||
avgCpm: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function LeagueCard({ league, index }: { league: AvailableLeague; index: number }) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const tierConfig = {
|
||||
premium: {
|
||||
bg: 'bg-gradient-to-br from-yellow-500/10 to-amber-500/5',
|
||||
border: 'border-yellow-500/30',
|
||||
badge: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||
icon: '⭐'
|
||||
},
|
||||
standard: {
|
||||
bg: 'bg-gradient-to-br from-primary-blue/10 to-cyan-500/5',
|
||||
border: 'border-primary-blue/30',
|
||||
badge: 'bg-primary-blue/20 text-primary-blue border-primary-blue/30',
|
||||
icon: '🏆'
|
||||
},
|
||||
starter: {
|
||||
bg: 'bg-gradient-to-br from-gray-500/10 to-slate-500/5',
|
||||
border: 'border-gray-500/30',
|
||||
badge: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||
icon: '🚀'
|
||||
},
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
active: { color: 'text-performance-green', bg: 'bg-performance-green/10', label: 'Active Season' },
|
||||
upcoming: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', label: 'Starting Soon' },
|
||||
completed: { color: 'text-gray-400', bg: 'bg-gray-400/10', label: 'Season Ended' },
|
||||
};
|
||||
|
||||
const config = league.tierConfig;
|
||||
const status = league.statusConfig;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<Card className={`overflow-hidden border ${config.border} ${config.bg} hover:border-primary-blue/50 transition-all duration-300 h-full`}>
|
||||
<div className="p-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium capitalize border ${config.badge}`}>
|
||||
{config.icon} {league.tier}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${status.bg} ${status.color}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-white text-lg">{league.name}</h3>
|
||||
<p className="text-sm text-gray-500">{league.game}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-iron-gray/50 px-2 py-1 rounded">
|
||||
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
|
||||
<span className="text-sm font-medium text-white">{league.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-gray-400 mb-4 line-clamp-2">{league.description}</p>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||
<div className="text-center p-2 bg-iron-gray/50 rounded-lg">
|
||||
<div className="text-lg font-bold text-white">{league.drivers}</div>
|
||||
<div className="text-xs text-gray-500">Drivers</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-iron-gray/50 rounded-lg">
|
||||
<div className="text-lg font-bold text-white">{league.formattedAvgViews}</div>
|
||||
<div className="text-xs text-gray-500">Avg Views</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-iron-gray/50 rounded-lg">
|
||||
<div className="text-lg font-bold text-performance-green">{league.formattedCpm}</div>
|
||||
<div className="text-xs text-gray-500">CPM</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next Race */}
|
||||
{league.nextRace && (
|
||||
<div className="flex items-center gap-2 mb-4 text-sm">
|
||||
<Clock className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-400">Next:</span>
|
||||
<span className="text-white">{league.nextRace}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sponsorship Slots */}
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center justify-between p-2.5 bg-iron-gray/30 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${league.mainSponsorSlot.available ? 'bg-performance-green' : 'bg-racing-red'}`} />
|
||||
<span className="text-sm text-gray-300">Main Sponsor</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{league.mainSponsorSlot.available ? (
|
||||
<span className="text-white font-semibold">${league.mainSponsorSlot.price}/season</span>
|
||||
) : (
|
||||
<span className="text-gray-500 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" /> Filled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2.5 bg-iron-gray/30 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${league.secondarySlots.available > 0 ? 'bg-performance-green' : 'bg-racing-red'}`} />
|
||||
<span className="text-sm text-gray-300">Secondary Slots</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{league.secondarySlots.available > 0 ? (
|
||||
<span className="text-white font-semibold">
|
||||
{league.secondarySlots.available}/{league.secondarySlots.total} @ ${league.secondarySlots.price}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-500 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" /> Full
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/sponsor/leagues/${league.id}`} className="flex-1">
|
||||
<Button variant="secondary" className="w-full text-sm">
|
||||
View Details
|
||||
</Button>
|
||||
</Link>
|
||||
{(league.mainSponsorSlot.available || league.secondarySlots.available > 0) && (
|
||||
<Link href={`/sponsor/leagues/${league.id}?action=sponsor`} className="flex-1">
|
||||
<Button variant="primary" className="w-full text-sm">
|
||||
Sponsor
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SponsorLeaguesTemplate({ data }: SponsorLeaguesTemplateProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [tierFilter, setTierFilter] = useState<TierFilter>('all');
|
||||
const [availabilityFilter, setAvailabilityFilter] = useState<AvailabilityFilter>('all');
|
||||
const [sortBy, setSortBy] = useState<SortOption>('rating');
|
||||
|
||||
// Filter and sort leagues
|
||||
const filteredLeagues = data.leagues
|
||||
.filter((league: any) => {
|
||||
if (searchQuery && !league.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (tierFilter !== 'all' && league.tier !== tierFilter) {
|
||||
return false;
|
||||
}
|
||||
if (availabilityFilter === 'main' && !league.mainSponsorSlot.available) {
|
||||
return false;
|
||||
}
|
||||
if (availabilityFilter === 'secondary' && league.secondarySlots.available === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a: any, b: any) => {
|
||||
switch (sortBy) {
|
||||
case 'rating': return b.rating - a.rating;
|
||||
case 'drivers': return b.drivers - a.drivers;
|
||||
case 'price': return a.mainSponsorSlot.price - b.mainSponsorSlot.price;
|
||||
case 'views': return b.avgViewsPerRace - a.avgViewsPerRace;
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const stats = data.stats;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-8 px-4">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 mb-6">
|
||||
<Link href="/sponsor/dashboard" className="hover:text-white transition-colors">Dashboard</Link>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<span className="text-white">Browse Leagues</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-2 flex items-center gap-3">
|
||||
<Trophy className="w-7 h-7 text-primary-blue" />
|
||||
League Sponsorship Marketplace
|
||||
</h1>
|
||||
<p className="text-gray-400">
|
||||
Discover racing leagues looking for sponsors. All prices shown exclude VAT.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-white">{stats.total}</div>
|
||||
<div className="text-sm text-gray-400">Leagues</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-performance-green">{stats.mainAvailable}</div>
|
||||
<div className="text-sm text-gray-400">Main Slots</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-primary-blue">{stats.secondaryAvailable}</div>
|
||||
<div className="text-sm text-gray-400">Secondary Slots</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-white">{stats.totalDrivers}</div>
|
||||
<div className="text-sm text-gray-400">Total Drivers</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-warning-amber">${stats.avgCpm}</div>
|
||||
<div className="text-sm text-gray-400">Avg CPM</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col lg:flex-row gap-4 mb-6">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search leagues..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white placeholder-gray-500 focus:border-primary-blue focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tier Filter */}
|
||||
<select
|
||||
value={tierFilter}
|
||||
onChange={(e) => setTierFilter(e.target.value as TierFilter)}
|
||||
className="px-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
|
||||
>
|
||||
<option value="all">All Tiers</option>
|
||||
<option value="premium">⭐ Premium</option>
|
||||
<option value="standard">🏆 Standard</option>
|
||||
<option value="starter">🚀 Starter</option>
|
||||
</select>
|
||||
|
||||
{/* Availability Filter */}
|
||||
<select
|
||||
value={availabilityFilter}
|
||||
onChange={(e) => setAvailabilityFilter(e.target.value as AvailabilityFilter)}
|
||||
className="px-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
|
||||
>
|
||||
<option value="all">All Slots</option>
|
||||
<option value="main">Main Available</option>
|
||||
<option value="secondary">Secondary Available</option>
|
||||
</select>
|
||||
|
||||
{/* Sort */}
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||
className="px-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
|
||||
>
|
||||
<option value="rating">Sort by Rating</option>
|
||||
<option value="drivers">Sort by Drivers</option>
|
||||
<option value="views">Sort by Views</option>
|
||||
<option value="price">Sort by Price</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Results Count */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<p className="text-sm text-gray-400">
|
||||
Showing {filteredLeagues.length} of {data.leagues.length} leagues
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/teams">
|
||||
<Button variant="secondary" className="text-sm">
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
Browse Teams
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/drivers">
|
||||
<Button variant="secondary" className="text-sm">
|
||||
<Car className="w-4 h-4 mr-2" />
|
||||
Browse Drivers
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* League Grid */}
|
||||
{filteredLeagues.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredLeagues.map((league: any, index: number) => (
|
||||
<LeagueCard key={league.id} league={league} index={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="text-center py-16">
|
||||
<Trophy className="w-12 h-12 text-gray-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-white mb-2">No leagues found</h3>
|
||||
<p className="text-gray-400 mb-6">Try adjusting your filters to see more results</p>
|
||||
<Button variant="secondary" onClick={() => {
|
||||
setSearchQuery('');
|
||||
setTierFilter('all');
|
||||
setAvailabilityFilter('all');
|
||||
}}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Platform Fee Notice */}
|
||||
<div className="mt-8 rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Megaphone className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-300 font-medium mb-1">Platform Fee</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
A {siteConfig.fees.platformFeePercent}% platform fee applies to all sponsorship payments. {siteConfig.fees.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
apps/website/templates/SponsorshipRequestsTemplate.tsx
Normal file
158
apps/website/templates/SponsorshipRequestsTemplate.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import PendingSponsorshipRequests from '@/components/sponsors/PendingSponsorshipRequests';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { AlertTriangle, Building, ChevronRight, Handshake, Trophy, User, Users } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export interface EntitySection {
|
||||
entityType: 'driver' | 'team' | 'race' | 'season';
|
||||
entityId: string;
|
||||
entityName: string;
|
||||
requests: any[];
|
||||
}
|
||||
|
||||
export interface SponsorshipRequestsTemplateProps {
|
||||
data: EntitySection[];
|
||||
onAccept: (requestId: string) => Promise<void>;
|
||||
onReject: (requestId: string, reason?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function SponsorshipRequestsTemplate({ data, onAccept, onReject }: SponsorshipRequestsTemplateProps) {
|
||||
const totalRequests = data.reduce((sum, s) => sum + s.requests.length, 0);
|
||||
|
||||
const getEntityIcon = (type: 'driver' | 'team' | 'race' | 'season') => {
|
||||
switch (type) {
|
||||
case 'driver':
|
||||
return User;
|
||||
case 'team':
|
||||
return Users;
|
||||
case 'race':
|
||||
return Trophy;
|
||||
case 'season':
|
||||
return Trophy;
|
||||
default:
|
||||
return Building;
|
||||
}
|
||||
};
|
||||
|
||||
const getEntityLink = (type: 'driver' | 'team' | 'race' | 'season', id: string) => {
|
||||
switch (type) {
|
||||
case 'driver':
|
||||
return `/drivers/${id}`;
|
||||
case 'team':
|
||||
return `/teams/${id}`;
|
||||
case 'race':
|
||||
return `/races/${id}`;
|
||||
case 'season':
|
||||
return `/leagues/${id}/sponsorships`;
|
||||
default:
|
||||
return '#';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Profile', href: '/profile' },
|
||||
{ label: 'Sponsorship Requests' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mt-6 mb-8">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-performance-green/10 border border-performance-green/30">
|
||||
<Handshake className="w-7 h-7 text-performance-green" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Sponsorship Requests</h1>
|
||||
<p className="text-sm text-gray-400">
|
||||
Manage sponsorship requests for your profile, teams, and leagues
|
||||
</p>
|
||||
</div>
|
||||
{totalRequests > 0 && (
|
||||
<div className="ml-auto px-3 py-1 rounded-full bg-performance-green/20 text-performance-green text-sm font-semibold">
|
||||
{totalRequests} pending
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.length === 0 ? (
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
|
||||
<Handshake className="w-8 h-8 text-gray-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">No Pending Requests</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
You don't have any pending sponsorship requests at the moment.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Sponsors can apply to sponsor your profile, teams, or leagues you manage.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{data.map((section) => {
|
||||
const Icon = getEntityIcon(section.entityType);
|
||||
const entityLink = getEntityLink(section.entityType, section.entityId);
|
||||
|
||||
return (
|
||||
<Card key={`${section.entityType}-${section.entityId}`}>
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center justify-between mb-6 pb-4 border-b border-charcoal-outline">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-iron-gray/50">
|
||||
<Icon className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">{section.entityName}</h2>
|
||||
<p className="text-xs text-gray-500 capitalize">{section.entityType}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={entityLink}
|
||||
className="flex items-center gap-1 text-sm text-primary-blue hover:text-primary-blue/80 transition-colors"
|
||||
>
|
||||
View {section.entityType === 'season' ? 'Sponsorships' : section.entityType}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Requests */}
|
||||
<PendingSponsorshipRequests
|
||||
entityType={section.entityType}
|
||||
entityId={section.entityId}
|
||||
entityName={section.entityName}
|
||||
requests={section.requests}
|
||||
onAccept={onAccept}
|
||||
onReject={onReject}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Card */}
|
||||
<Card className="mt-8 bg-gradient-to-r from-primary-blue/5 to-transparent border-primary-blue/20">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/20 flex-shrink-0">
|
||||
<Building className="w-5 h-5 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-1">How Sponsorships Work</h3>
|
||||
<p className="text-xs text-gray-400 leading-relaxed">
|
||||
Sponsors can apply to sponsor your driver profile, teams you manage, or leagues you administer.
|
||||
Review each request carefully - accepting will activate the sponsorship and the sponsor will be
|
||||
charged. You'll receive the payment minus a 10% platform fee.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user