diff --git a/apps/website/app/api/payments/membership-fees/route.ts b/apps/website/app/api/payments/membership-fees/route.ts new file mode 100644 index 000000000..7c650c1e5 --- /dev/null +++ b/apps/website/app/api/payments/membership-fees/route.ts @@ -0,0 +1,171 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// Alpha: In-memory membership fee storage +const membershipFees: Map = new Map(); + +const memberPayments: Map = new Map(); + +const PLATFORM_FEE_RATE = 0.10; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const leagueId = searchParams.get('leagueId'); + const driverId = searchParams.get('driverId'); + + if (!leagueId) { + return NextResponse.json( + { error: 'leagueId is required' }, + { status: 400 } + ); + } + + const fee = Array.from(membershipFees.values()).find(f => f.leagueId === leagueId); + + let payments: typeof memberPayments extends Map ? V[] : never[] = []; + if (driverId) { + payments = Array.from(memberPayments.values()).filter( + p => membershipFees.get(p.feeId)?.leagueId === leagueId && p.driverId === driverId + ); + } + + return NextResponse.json({ fee, payments }); +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { leagueId, seasonId, type, amount } = body; + + if (!leagueId || !type || amount === undefined) { + return NextResponse.json( + { error: 'Missing required fields: leagueId, type, amount' }, + { status: 400 } + ); + } + + if (!['season', 'monthly', 'per_race'].includes(type)) { + return NextResponse.json( + { error: 'Type must be "season", "monthly", or "per_race"' }, + { status: 400 } + ); + } + + // Check for existing fee config + const existingFee = Array.from(membershipFees.values()).find(f => f.leagueId === leagueId); + + if (existingFee) { + // Update existing fee + existingFee.type = type; + existingFee.amount = amount; + existingFee.seasonId = seasonId || existingFee.seasonId; + existingFee.enabled = amount > 0; + existingFee.updatedAt = new Date(); + membershipFees.set(existingFee.id, existingFee); + return NextResponse.json({ fee: existingFee }); + } + + const id = `fee-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const fee = { + id, + leagueId, + seasonId: seasonId || undefined, + type, + amount, + enabled: amount > 0, + createdAt: new Date(), + updatedAt: new Date(), + }; + + membershipFees.set(id, fee); + + return NextResponse.json({ fee }, { status: 201 }); + } catch (err) { + console.error('Membership fee creation failed:', err); + return NextResponse.json( + { error: 'Failed to create membership fee' }, + { status: 500 } + ); + } +} + +// Record a member payment +export async function PATCH(request: NextRequest) { + try { + const body = await request.json(); + const { feeId, driverId, status, paidAt } = body; + + if (!feeId || !driverId) { + return NextResponse.json( + { error: 'Missing required fields: feeId, driverId' }, + { status: 400 } + ); + } + + const fee = membershipFees.get(feeId); + if (!fee) { + return NextResponse.json( + { error: 'Membership fee configuration not found' }, + { status: 404 } + ); + } + + // Find or create payment record + let payment = Array.from(memberPayments.values()).find( + p => p.feeId === feeId && p.driverId === driverId + ); + + if (!payment) { + const platformFee = fee.amount * PLATFORM_FEE_RATE; + const netAmount = fee.amount - platformFee; + + const paymentId = `mp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + payment = { + id: paymentId, + feeId, + driverId, + amount: fee.amount, + platformFee, + netAmount, + status: 'pending', + dueDate: new Date(), + }; + memberPayments.set(paymentId, payment); + } + + if (status) { + payment.status = status; + } + if (paidAt || status === 'paid') { + payment.paidAt = paidAt ? new Date(paidAt) : new Date(); + } + + memberPayments.set(payment.id, payment); + + return NextResponse.json({ payment }); + } catch (err) { + console.error('Member payment update failed:', err); + return NextResponse.json( + { error: 'Failed to update member payment' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/website/app/api/payments/prizes/route.ts b/apps/website/app/api/payments/prizes/route.ts new file mode 100644 index 000000000..9d932bb11 --- /dev/null +++ b/apps/website/app/api/payments/prizes/route.ts @@ -0,0 +1,180 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// Alpha: In-memory prize storage +const prizes: Map = new Map(); + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const leagueId = searchParams.get('leagueId'); + const seasonId = searchParams.get('seasonId'); + + if (!leagueId) { + return NextResponse.json( + { error: 'leagueId is required' }, + { status: 400 } + ); + } + + let results = Array.from(prizes.values()).filter(p => p.leagueId === leagueId); + + if (seasonId) { + results = results.filter(p => p.seasonId === seasonId); + } + + results.sort((a, b) => a.position - b.position); + + return NextResponse.json({ prizes: results }); +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { leagueId, seasonId, position, name, amount, type, description } = body; + + if (!leagueId || !seasonId || !position || !name || amount === undefined || !type) { + return NextResponse.json( + { error: 'Missing required fields: leagueId, seasonId, position, name, amount, type' }, + { status: 400 } + ); + } + + if (!['cash', 'merchandise', 'other'].includes(type)) { + return NextResponse.json( + { error: 'Type must be \"cash\", \"merchandise\", or \"other\"' }, + { status: 400 } + ); + } + + // Check for duplicate position + const existingPrize = Array.from(prizes.values()).find( + p => p.leagueId === leagueId && p.seasonId === seasonId && p.position === position + ); + + if (existingPrize) { + return NextResponse.json( + { error: `Prize for position ${position} already exists` }, + { status: 409 } + ); + } + + const id = `prize-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const prize = { + id, + leagueId, + seasonId, + position, + name, + amount, + type, + description: description || undefined, + awarded: false, + createdAt: new Date(), + }; + + prizes.set(id, prize); + + return NextResponse.json({ prize }, { status: 201 }); + } catch (err) { + console.error('Prize creation failed:', err); + return NextResponse.json( + { error: 'Failed to create prize' }, + { status: 500 } + ); + } +} + +// Award a prize +export async function PATCH(request: NextRequest) { + try { + const body = await request.json(); + const { prizeId, driverId } = body; + + if (!prizeId || !driverId) { + return NextResponse.json( + { error: 'Missing required fields: prizeId, driverId' }, + { status: 400 } + ); + } + + const prize = prizes.get(prizeId); + if (!prize) { + return NextResponse.json( + { error: 'Prize not found' }, + { status: 404 } + ); + } + + if (prize.awarded) { + return NextResponse.json( + { error: 'Prize has already been awarded' }, + { status: 400 } + ); + } + + prize.awarded = true; + prize.awardedTo = driverId; + prize.awardedAt = new Date(); + + prizes.set(prizeId, prize); + + return NextResponse.json({ prize }); + } catch (err) { + console.error('Prize awarding failed:', err); + return NextResponse.json( + { error: 'Failed to award prize' }, + { status: 500 } + ); + } +} + +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const prizeId = searchParams.get('prizeId'); + + if (!prizeId) { + return NextResponse.json( + { error: 'prizeId is required' }, + { status: 400 } + ); + } + + const prize = prizes.get(prizeId); + if (!prize) { + return NextResponse.json( + { error: 'Prize not found' }, + { status: 404 } + ); + } + + if (prize.awarded) { + return NextResponse.json( + { error: 'Cannot delete an awarded prize' }, + { status: 400 } + ); + } + + prizes.delete(prizeId); + + return NextResponse.json({ success: true }); + } catch (err) { + console.error('Prize deletion failed:', err); + return NextResponse.json( + { error: 'Failed to delete prize' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/website/app/api/payments/route.ts b/apps/website/app/api/payments/route.ts new file mode 100644 index 000000000..2359f2438 --- /dev/null +++ b/apps/website/app/api/payments/route.ts @@ -0,0 +1,141 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// Alpha: In-memory payment storage (mock payment gateway) +const payments: Map = new Map(); + +const PLATFORM_FEE_RATE = 0.10; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const leagueId = searchParams.get('leagueId'); + const payerId = searchParams.get('payerId'); + const type = searchParams.get('type'); + + let results = Array.from(payments.values()); + + if (leagueId) { + results = results.filter(p => p.leagueId === leagueId); + } + if (payerId) { + results = results.filter(p => p.payerId === payerId); + } + if (type) { + results = results.filter(p => p.type === type); + } + + return NextResponse.json({ payments: results }); +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { type, amount, payerId, payerType, leagueId, seasonId } = body; + + if (!type || !amount || !payerId || !payerType || !leagueId) { + return NextResponse.json( + { error: 'Missing required fields: type, amount, payerId, payerType, leagueId' }, + { status: 400 } + ); + } + + if (!['sponsorship', 'membership_fee'].includes(type)) { + return NextResponse.json( + { error: 'Type must be "sponsorship" or "membership_fee"' }, + { status: 400 } + ); + } + + if (!['sponsor', 'driver'].includes(payerType)) { + return NextResponse.json( + { error: 'PayerType must be "sponsor" or "driver"' }, + { status: 400 } + ); + } + + const platformFee = amount * PLATFORM_FEE_RATE; + const netAmount = amount - platformFee; + + const id = `payment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const payment = { + id, + type, + amount, + platformFee, + netAmount, + payerId, + payerType, + leagueId, + seasonId: seasonId || undefined, + status: 'pending' as const, + createdAt: new Date(), + }; + + payments.set(id, payment); + + return NextResponse.json({ payment }, { status: 201 }); + } catch (err) { + console.error('Payment creation failed:', err); + return NextResponse.json( + { error: 'Failed to create payment' }, + { status: 500 } + ); + } +} + +// Complete a payment (mock payment gateway callback) +export async function PATCH(request: NextRequest) { + try { + const body = await request.json(); + const { paymentId, status } = body; + + if (!paymentId || !status) { + return NextResponse.json( + { error: 'Missing required fields: paymentId, status' }, + { status: 400 } + ); + } + + if (!['completed', 'failed', 'refunded'].includes(status)) { + return NextResponse.json( + { error: 'Status must be "completed", "failed", or "refunded"' }, + { status: 400 } + ); + } + + const payment = payments.get(paymentId); + if (!payment) { + return NextResponse.json( + { error: 'Payment not found' }, + { status: 404 } + ); + } + + payment.status = status; + if (status === 'completed') { + payment.completedAt = new Date(); + } + + payments.set(paymentId, payment); + + return NextResponse.json({ payment }); + } catch (err) { + console.error('Payment update failed:', err); + return NextResponse.json( + { error: 'Failed to update payment' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/website/app/api/payments/wallets/route.ts b/apps/website/app/api/payments/wallets/route.ts new file mode 100644 index 000000000..0d1b5159b --- /dev/null +++ b/apps/website/app/api/payments/wallets/route.ts @@ -0,0 +1,142 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// Alpha: In-memory wallet storage +const wallets: Map = new Map(); + +const transactions: Map = new Map(); + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const leagueId = searchParams.get('leagueId'); + + if (!leagueId) { + return NextResponse.json( + { error: 'leagueId is required' }, + { status: 400 } + ); + } + + let wallet = Array.from(wallets.values()).find(w => w.leagueId === leagueId); + + if (!wallet) { + // Create wallet if doesn't exist + const id = `wallet-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + wallet = { + id, + leagueId, + balance: 0, + totalRevenue: 0, + totalPlatformFees: 0, + totalWithdrawn: 0, + createdAt: new Date(), + }; + wallets.set(id, wallet); + } + + const walletTransactions = Array.from(transactions.values()) + .filter(t => t.walletId === wallet!.id) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + return NextResponse.json({ wallet, transactions: walletTransactions }); +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { leagueId, type, amount, description, referenceId, referenceType } = body; + + if (!leagueId || !type || !amount || !description) { + return NextResponse.json( + { error: 'Missing required fields: leagueId, type, amount, description' }, + { status: 400 } + ); + } + + if (!['deposit', 'withdrawal'].includes(type)) { + return NextResponse.json( + { error: 'Type must be "deposit" or "withdrawal"' }, + { status: 400 } + ); + } + + // Get or create wallet + let wallet = Array.from(wallets.values()).find(w => w.leagueId === leagueId); + + if (!wallet) { + const id = `wallet-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + wallet = { + id, + leagueId, + balance: 0, + totalRevenue: 0, + totalPlatformFees: 0, + totalWithdrawn: 0, + createdAt: new Date(), + }; + wallets.set(id, wallet); + } + + if (type === 'withdrawal') { + if (amount > wallet.balance) { + return NextResponse.json( + { error: 'Insufficient balance' }, + { status: 400 } + ); + } + + // Alpha: In production, check if season is over before allowing withdrawal + // For now we allow it for demo purposes + } + + // Create transaction + const transactionId = `txn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const transaction = { + id: transactionId, + walletId: wallet.id, + type, + amount, + description, + referenceId: referenceId || undefined, + referenceType: referenceType || undefined, + createdAt: new Date(), + }; + + transactions.set(transactionId, transaction); + + // Update wallet balance + if (type === 'deposit') { + wallet.balance += amount; + wallet.totalRevenue += amount; + } else { + wallet.balance -= amount; + wallet.totalWithdrawn += amount; + } + + wallets.set(wallet.id, wallet); + + return NextResponse.json({ wallet, transaction }, { status: 201 }); + } catch (err) { + console.error('Wallet transaction failed:', err); + return NextResponse.json( + { error: 'Failed to process wallet transaction' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/website/app/api/sponsors/dashboard/route.ts b/apps/website/app/api/sponsors/dashboard/route.ts new file mode 100644 index 000000000..23d5d854e --- /dev/null +++ b/apps/website/app/api/sponsors/dashboard/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { cookies } from 'next/headers'; +import { getGetSponsorDashboardQuery } from '@/lib/di-container'; + +export async function GET(request: NextRequest) { + try { + // Get sponsor ID from cookie (set during demo login) + const cookieStore = await cookies(); + const sponsorId = cookieStore.get('gridpilot_sponsor_id')?.value; + + if (!sponsorId) { + return NextResponse.json( + { error: 'Not authenticated as sponsor' }, + { status: 401 } + ); + } + + const query = getGetSponsorDashboardQuery(); + const dashboard = await query.execute({ sponsorId }); + + if (!dashboard) { + return NextResponse.json( + { error: 'Sponsor not found' }, + { status: 404 } + ); + } + + return NextResponse.json(dashboard); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get sponsor dashboard'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/apps/website/app/api/sponsors/route.ts b/apps/website/app/api/sponsors/route.ts new file mode 100644 index 000000000..9244b7b96 --- /dev/null +++ b/apps/website/app/api/sponsors/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// Alpha: In-memory sponsor storage +const sponsors: Map = new Map(); + +export async function GET() { + const allSponsors = Array.from(sponsors.values()); + return NextResponse.json({ sponsors: allSponsors }); +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { name, contactEmail, websiteUrl, logoUrl } = body; + + if (!name || !contactEmail) { + return NextResponse.json( + { error: 'Name and contact email are required' }, + { status: 400 } + ); + } + + const id = `sponsor-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const sponsor = { + id, + name, + contactEmail, + websiteUrl: websiteUrl || undefined, + logoUrl: logoUrl || undefined, + createdAt: new Date(), + }; + + sponsors.set(id, sponsor); + + return NextResponse.json({ sponsor }, { status: 201 }); + } catch (err) { + console.error('Sponsor creation failed:', err); + return NextResponse.json( + { error: 'Failed to create sponsor' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/website/app/api/sponsors/sponsorships/route.ts b/apps/website/app/api/sponsors/sponsorships/route.ts new file mode 100644 index 000000000..6c7349586 --- /dev/null +++ b/apps/website/app/api/sponsors/sponsorships/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { cookies } from 'next/headers'; +import { getGetSponsorSponsorshipsQuery } from '@/lib/di-container'; + +export async function GET(request: NextRequest) { + try { + // Get sponsor ID from cookie (set during demo login) + const cookieStore = await cookies(); + const sponsorId = cookieStore.get('gridpilot_sponsor_id')?.value; + + if (!sponsorId) { + return NextResponse.json( + { error: 'Not authenticated as sponsor' }, + { status: 401 } + ); + } + + const query = getGetSponsorSponsorshipsQuery(); + const sponsorships = await query.execute({ sponsorId }); + + if (!sponsorships) { + return NextResponse.json( + { error: 'Sponsor not found' }, + { status: 404 } + ); + } + + return NextResponse.json(sponsorships); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get sponsor sponsorships'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/apps/website/app/api/wallets/[leagueId]/withdraw/route.ts b/apps/website/app/api/wallets/[leagueId]/withdraw/route.ts new file mode 100644 index 000000000..2e3818d9c --- /dev/null +++ b/apps/website/app/api/wallets/[leagueId]/withdraw/route.ts @@ -0,0 +1,202 @@ +/** + * API Route: Wallet Withdrawal + * + * POST /api/wallets/:leagueId/withdraw + * + * Handles withdrawal requests from league wallets. + * Enforces the rule that withdrawals are only allowed after season is completed. + */ + +import { NextRequest, NextResponse } from 'next/server'; + +interface WithdrawRequest { + amount: number; + currency: string; + seasonId: string; + destinationAccount: string; +} + +// Mock season status lookup +const MOCK_SEASONS: Record = { + 'season-1': { status: 'completed', name: 'Season 1' }, + 'season-2': { status: 'active', name: 'Season 2' }, + 'season-3': { status: 'planned', name: 'Season 3' }, +}; + +// Mock wallet balances +const MOCK_WALLETS: Record = { + 'league-1': { balance: 2500, currency: 'USD' }, + 'league-2': { balance: 1200, currency: 'USD' }, +}; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ leagueId: string }> } +) { + try { + const { leagueId } = await params; + const body: WithdrawRequest = await request.json(); + + // Validate required fields + if (!body.amount || body.amount <= 0) { + return NextResponse.json( + { error: 'Invalid withdrawal amount' }, + { status: 400 } + ); + } + + if (!body.seasonId) { + return NextResponse.json( + { error: 'Season ID is required' }, + { status: 400 } + ); + } + + if (!body.destinationAccount) { + return NextResponse.json( + { error: 'Destination account is required' }, + { status: 400 } + ); + } + + // Get season status + const season = MOCK_SEASONS[body.seasonId]; + if (!season) { + return NextResponse.json( + { error: 'Season not found' }, + { status: 404 } + ); + } + + // CRITICAL: Enforce withdrawal restriction based on season status + // Withdrawals are ONLY allowed when the season is completed + if (season.status !== 'completed') { + return NextResponse.json( + { + error: 'Withdrawal not allowed', + reason: `Withdrawals are only permitted after the season is completed. "${season.name}" is currently ${season.status}.`, + seasonStatus: season.status, + }, + { status: 403 } + ); + } + + // Get wallet + const wallet = MOCK_WALLETS[leagueId]; + if (!wallet) { + return NextResponse.json( + { error: 'Wallet not found' }, + { status: 404 } + ); + } + + // Check sufficient balance + if (wallet.balance < body.amount) { + return NextResponse.json( + { + error: 'Insufficient balance', + available: wallet.balance, + requested: body.amount, + }, + { status: 400 } + ); + } + + // Check currency match + if (wallet.currency !== body.currency) { + return NextResponse.json( + { + error: 'Currency mismatch', + walletCurrency: wallet.currency, + requestedCurrency: body.currency, + }, + { status: 400 } + ); + } + + // Process withdrawal (in-memory mock) + const newBalance = wallet.balance - body.amount; + MOCK_WALLETS[leagueId] = { ...wallet, balance: newBalance }; + + // Generate transaction ID + const transactionId = `txn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + return NextResponse.json({ + success: true, + transactionId, + amount: body.amount, + currency: body.currency, + newBalance, + destinationAccount: body.destinationAccount, + processedAt: new Date().toISOString(), + }); + + } catch (error) { + console.error('Wallet withdrawal error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +/** + * GET /api/wallets/:leagueId/withdraw + * + * Check withdrawal eligibility for a league's wallet + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ leagueId: string }> } +) { + try { + const { leagueId } = await params; + const { searchParams } = new URL(request.url); + const seasonId = searchParams.get('seasonId'); + + if (!seasonId) { + return NextResponse.json( + { error: 'Season ID is required' }, + { status: 400 } + ); + } + + const season = MOCK_SEASONS[seasonId]; + if (!season) { + return NextResponse.json( + { error: 'Season not found' }, + { status: 404 } + ); + } + + const wallet = MOCK_WALLETS[leagueId]; + if (!wallet) { + return NextResponse.json( + { error: 'Wallet not found' }, + { status: 404 } + ); + } + + const canWithdraw = season.status === 'completed'; + + return NextResponse.json({ + leagueId, + seasonId, + seasonName: season.name, + seasonStatus: season.status, + canWithdraw, + reason: canWithdraw + ? 'Season is completed, withdrawals are allowed' + : `Withdrawals are only permitted after the season is completed. Season is currently ${season.status}.`, + balance: wallet.balance, + currency: wallet.currency, + }); + + } catch (error) { + console.error('Withdrawal eligibility check error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/website/app/drivers/[id]/page.tsx b/apps/website/app/drivers/[id]/page.tsx index e8403be5d..5e8891934 100644 --- a/apps/website/app/drivers/[id]/page.tsx +++ b/apps/website/app/drivers/[id]/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, use } from 'react'; import Image from 'next/image'; import Link from 'next/link'; import { useRouter, useParams } from 'next/navigation'; +import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard'; import { User, Trophy, @@ -32,6 +33,8 @@ import { Shield, Percent, Activity, + Megaphone, + DollarSign, } from 'lucide-react'; import { getDriverRepository, @@ -350,6 +353,8 @@ export default function DriverDetailPage({ backLink = null; } + const isSponsorMode = useSponsorMode(); + useEffect(() => { loadDriver(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -447,6 +452,14 @@ export default function DriverDetailPage({ const allRankings = getAllDriverRankings(); const globalRank = stats?.overallRank ?? allRankings.findIndex(r => r.driverId === driver.id) + 1; + // Build sponsor insights for driver + const driverMetrics = [ + MetricBuilders.rating(stats?.rating ?? 0, 'Driver Rating'), + MetricBuilders.views((friends.length * 8) + 50), + MetricBuilders.engagement(stats?.consistency ?? 75), + MetricBuilders.reach((friends.length * 12) + 100), + ]; + return (
{/* Back Navigation */} @@ -478,6 +491,20 @@ export default function DriverDetailPage({ ]} /> + {/* Sponsor Insights Card - Consistent placement at top */} + {isSponsorMode && driver && ( + + )} + {/* Hero Header Section */}
{/* Background Pattern */} diff --git a/apps/website/app/leagues/[id]/layout.tsx b/apps/website/app/leagues/[id]/layout.tsx index 43f6723b7..85b87e193 100644 --- a/apps/website/app/leagues/[id]/layout.tsx +++ b/apps/website/app/leagues/[id]/layout.tsx @@ -4,11 +4,25 @@ import React, { useState, useEffect } from 'react'; import { useParams, usePathname, useRouter } from 'next/navigation'; import Breadcrumbs from '@/components/layout/Breadcrumbs'; import LeagueHeader from '@/components/leagues/LeagueHeader'; -import { getLeagueRepository, getDriverRepository, getLeagueMembershipRepository } from '@/lib/di-container'; +import { + getLeagueRepository, + getDriverRepository, + getLeagueMembershipRepository, + getSeasonRepository, + getSponsorRepository, + getSeasonSponsorshipRepository, +} from '@/lib/di-container'; import { useEffectiveDriverId } from '@/lib/currentDriver'; import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; import type { League } from '@gridpilot/racing/domain/entities/League'; +// Main sponsor info for "by XYZ" display +interface MainSponsorInfo { + name: string; + logoUrl?: string; + websiteUrl?: string; +} + export default function LeagueLayout({ children, }: { @@ -22,6 +36,7 @@ export default function LeagueLayout({ const [league, setLeague] = useState(null); const [ownerName, setOwnerName] = useState(''); + const [mainSponsor, setMainSponsor] = useState(null); const [isAdmin, setIsAdmin] = useState(false); const [loading, setLoading] = useState(true); @@ -31,6 +46,9 @@ export default function LeagueLayout({ const leagueRepo = getLeagueRepository(); const driverRepo = getDriverRepository(); const membershipRepo = getLeagueMembershipRepository(); + const seasonRepo = getSeasonRepository(); + const sponsorRepo = getSponsorRepository(); + const sponsorshipRepo = getSeasonSponsorshipRepository(); const leagueData = await leagueRepo.findById(leagueId); @@ -47,6 +65,30 @@ export default function LeagueLayout({ // Check if current user is admin const membership = await membershipRepo.getMembership(leagueId, currentDriverId); setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false); + + // Load main sponsor for "by XYZ" display + try { + const seasons = await seasonRepo.findByLeagueId(leagueId); + const activeSeason = seasons.find((s: { status: string }) => s.status === 'active') ?? seasons[0]; + + if (activeSeason) { + const sponsorships = await sponsorshipRepo.findBySeasonId(activeSeason.id); + const mainSponsorship = sponsorships.find(s => s.tier === 'main' && s.status === 'active'); + + if (mainSponsorship) { + const sponsor = await sponsorRepo.findById(mainSponsorship.sponsorId); + if (sponsor) { + setMainSponsor({ + name: sponsor.name, + logoUrl: sponsor.logoUrl, + websiteUrl: sponsor.websiteUrl, + }); + } + } + } + } catch (sponsorError) { + console.warn('Failed to load main sponsor:', sponsorError); + } } catch (error) { console.error('Failed to load league:', error); } finally { @@ -86,7 +128,9 @@ export default function LeagueLayout({ ]; const adminTabs = [ + { label: 'Sponsorships', href: `/leagues/${leagueId}/sponsorships`, exact: false }, { label: 'Stewarding', href: `/leagues/${leagueId}/stewarding`, exact: false }, + { label: 'Wallet', href: `/leagues/${leagueId}/wallet`, exact: false }, { label: 'Settings', href: `/leagues/${leagueId}/settings`, exact: false }, ]; @@ -114,6 +158,7 @@ export default function LeagueLayout({ description={league.description} ownerId={league.ownerId} ownerName={ownerName} + mainSponsor={mainSponsor} /> {/* Tab Navigation */} diff --git a/apps/website/app/leagues/[id]/page.tsx b/apps/website/app/leagues/[id]/page.tsx index 6297f334e..adde5d22e 100644 --- a/apps/website/app/leagues/[id]/page.tsx +++ b/apps/website/app/leagues/[id]/page.tsx @@ -8,6 +8,12 @@ import JoinLeagueButton from '@/components/leagues/JoinLeagueButton'; import LeagueActivityFeed from '@/components/leagues/LeagueActivityFeed'; import DriverIdentity from '@/components/drivers/DriverIdentity'; import DriverSummaryPill from '@/components/profile/DriverSummaryPill'; +import SponsorInsightsCard, { + useSponsorMode, + MetricBuilders, + SlotTemplates, + type SponsorMetric, +} from '@/components/sponsors/SponsorInsightsCard'; import { League, Driver, @@ -23,16 +29,30 @@ import { getDriverStats, getAllDriverRankings, getGetLeagueStatsQuery, + getSeasonRepository, + getSponsorRepository, + getSeasonSponsorshipRepository, } from '@/lib/di-container'; -import { Zap, Users, Trophy, Calendar } from 'lucide-react'; +import { Trophy, Star, ExternalLink } from 'lucide-react'; import { getMembership, getLeagueMembers } from '@/lib/leagueMembership'; import { useEffectiveDriverId } from '@/lib/currentDriver'; import { getLeagueRoleDisplay } from '@/lib/leagueRoles'; +// Sponsor info type +interface SponsorInfo { + id: string; + name: string; + logoUrl?: string; + websiteUrl?: string; + tier: 'main' | 'secondary'; + tagline?: string; +} + export default function LeagueDetailPage() { const router = useRouter(); const params = useParams(); const leagueId = params.id as string; + const isSponsor = useSponsorMode(); const [league, setLeague] = useState(null); const [owner, setOwner] = useState(null); @@ -40,12 +60,44 @@ export default function LeagueDetailPage() { const [scoringConfig, setScoringConfig] = useState(null); const [averageSOF, setAverageSOF] = useState(null); const [completedRacesCount, setCompletedRacesCount] = useState(0); + const [sponsors, setSponsors] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [refreshKey, setRefreshKey] = useState(0); const currentDriverId = useEffectiveDriverId(); const membership = getMembership(leagueId, currentDriverId); + const leagueMemberships = getLeagueMembers(leagueId); + + // Sponsor insights data - uses leagueMemberships and averageSOF + const sponsorInsights = useMemo(() => { + const memberCount = leagueMemberships?.length || 20; + const mainSponsorTaken = sponsors.some(s => s.tier === 'main'); + const secondaryTaken = sponsors.filter(s => s.tier === 'secondary').length; + + return { + avgViewsPerRace: 5400 + memberCount * 50, + totalImpressions: 45000 + memberCount * 500, + engagementRate: (3.5 + (memberCount / 50)).toFixed(1), + estimatedReach: memberCount * 150, + mainSponsorAvailable: !mainSponsorTaken, + secondarySlotsAvailable: Math.max(0, 2 - secondaryTaken), + mainSponsorPrice: 800 + Math.floor(memberCount * 10), + secondaryPrice: 250 + Math.floor(memberCount * 3), + tier: (averageSOF && averageSOF > 3000 ? 'premium' : averageSOF && averageSOF > 2000 ? 'standard' : 'starter') as 'premium' | 'standard' | 'starter', + trustScore: Math.min(100, 60 + memberCount + completedRacesCount), + discordMembers: memberCount * 3, + monthlyActivity: Math.min(100, 40 + completedRacesCount * 2), + }; + }, [averageSOF, leagueMemberships?.length, sponsors, completedRacesCount]); + + // Build metrics for SponsorInsightsCard + const leagueMetrics: SponsorMetric[] = useMemo(() => [ + MetricBuilders.views(sponsorInsights.avgViewsPerRace, 'Avg Views/Race'), + MetricBuilders.engagement(sponsorInsights.engagementRate), + MetricBuilders.reach(sponsorInsights.estimatedReach), + MetricBuilders.sof(averageSOF ?? '—'), + ], [sponsorInsights, averageSOF]); const loadLeagueData = async () => { try { @@ -53,6 +105,9 @@ export default function LeagueDetailPage() { const raceRepo = getRaceRepository(); const driverRepo = getDriverRepository(); const leagueStatsQuery = getGetLeagueStatsQuery(); + const seasonRepo = getSeasonRepository(); + const sponsorRepo = getSponsorRepository(); + const sponsorshipRepo = getSeasonSponsorshipRepository(); const leagueData = await leagueRepo.findById(leagueId); @@ -92,6 +147,47 @@ export default function LeagueDetailPage() { const completedRaces = leagueRaces.filter(r => r.status === 'completed'); setCompletedRacesCount(completedRaces.length); } + + // Load sponsors for this league + try { + const seasons = await seasonRepo.findByLeagueId(leagueId); + const activeSeason = seasons.find((s: { status: string }) => s.status === 'active') ?? seasons[0]; + + if (activeSeason) { + const sponsorships = await sponsorshipRepo.findBySeasonId(activeSeason.id); + const activeSponsorships = sponsorships.filter(s => s.status === 'active'); + + const sponsorInfos: SponsorInfo[] = []; + for (const sponsorship of activeSponsorships) { + const sponsor = await sponsorRepo.findById(sponsorship.sponsorId); + if (sponsor) { + // Get tagline from demo data if available + const demoSponsors = (await import('@gridpilot/testing-support')).sponsors; + const demoSponsor = demoSponsors.find((s: any) => s.id === sponsor.id); + + sponsorInfos.push({ + id: sponsor.id, + name: sponsor.name, + logoUrl: sponsor.logoUrl, + websiteUrl: sponsor.websiteUrl, + tier: sponsorship.tier, + tagline: demoSponsor?.tagline, + }); + } + } + + // Sort: main sponsors first, then secondary + sponsorInfos.sort((a, b) => { + if (a.tier === 'main' && b.tier !== 'main') return -1; + if (a.tier !== 'main' && b.tier === 'main') return 1; + return 0; + }); + + setSponsors(sponsorInfos); + } + } catch (sponsorError) { + console.warn('Failed to load sponsors:', sponsorError); + } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load league'); } finally { @@ -117,7 +213,6 @@ export default function LeagueDetailPage() { return map; }, [drivers]); - const leagueMemberships = getLeagueMembers(leagueId); const ownerMembership = leagueMemberships.find((m) => m.role === 'owner') ?? null; const adminMemberships = leagueMemberships.filter((m) => m.role === 'admin'); const stewardMemberships = leagueMemberships.filter((m) => m.role === 'steward'); @@ -179,8 +274,36 @@ export default function LeagueDetailPage() { ) : ( <> + {/* Sponsor Insights Card - Only shown to sponsors, at top of page */} + {isSponsor && league && ( + + )} + {/* Action Card */} - {!membership && ( + {!membership && !isSponsor && (
@@ -288,6 +411,102 @@ export default function LeagueDetailPage() { )} + {/* Sponsors Section - Show sponsor logos */} + {sponsors.length > 0 && ( + +

+ {sponsors.find(s => s.tier === 'main') ? 'Presented by' : 'Sponsors'} +

+
+ {/* Main Sponsor - Featured prominently */} + {sponsors.filter(s => s.tier === 'main').map(sponsor => ( +
+
+ {sponsor.logoUrl ? ( +
+ {sponsor.name} +
+ ) : ( +
+ +
+ )} +
+
+ {sponsor.name} + + Main + +
+ {sponsor.tagline && ( +

{sponsor.tagline}

+ )} +
+ {sponsor.websiteUrl && ( + + + + )} +
+
+ ))} + + {/* Secondary Sponsors - Smaller display */} + {sponsors.filter(s => s.tier === 'secondary').length > 0 && ( +
+ {sponsors.filter(s => s.tier === 'secondary').map(sponsor => ( +
+
+ {sponsor.logoUrl ? ( +
+ {sponsor.name} +
+ ) : ( +
+ +
+ )} +
+ {sponsor.name} +
+ {sponsor.websiteUrl && ( + + + + )} +
+
+ ))} +
+ )} +
+
+ )} + {/* Management */} {(ownerMembership || adminMemberships.length > 0 || stewardMemberships.length > 0) && ( diff --git a/apps/website/app/leagues/[id]/sponsorships/page.tsx b/apps/website/app/leagues/[id]/sponsorships/page.tsx new file mode 100644 index 000000000..834921d8b --- /dev/null +++ b/apps/website/app/leagues/[id]/sponsorships/page.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams } from 'next/navigation'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import { LeagueSponsorshipsSection } from '@/components/leagues/LeagueSponsorshipsSection'; +import { + getLeagueRepository, + getLeagueMembershipRepository, +} from '@/lib/di-container'; +import { useEffectiveDriverId } from '@/lib/currentDriver'; +import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; +import { AlertTriangle, Building, DollarSign } from 'lucide-react'; +import type { League } from '@gridpilot/racing/domain/entities/League'; + +export default function LeagueSponsorshipsPage() { + const params = useParams(); + const leagueId = params.id as string; + const currentDriverId = useEffectiveDriverId(); + + const [league, setLeague] = useState(null); + const [isAdmin, setIsAdmin] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function loadData() { + try { + const leagueRepo = getLeagueRepository(); + const membershipRepo = getLeagueMembershipRepository(); + + const [leagueData, membership] = await Promise.all([ + leagueRepo.findById(leagueId), + membershipRepo.getMembership(leagueId, currentDriverId), + ]); + + setLeague(leagueData); + setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false); + } catch (err) { + console.error('Failed to load league:', err); + } finally { + setLoading(false); + } + } + + loadData(); + }, [leagueId, currentDriverId]); + + if (loading) { + return ( + +
Loading sponsorships...
+
+ ); + } + + if (!isAdmin) { + return ( + +
+
+ +
+

Admin Access Required

+

+ Only league admins can manage sponsorships. +

+
+
+ ); + } + + if (!league) { + return ( + +
+ League not found. +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+
+

Sponsorships

+

Manage sponsorship slots and review requests

+
+
+ + {/* Sponsorships Section */} + + + +
+ ); +} \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/wallet/page.tsx b/apps/website/app/leagues/[id]/wallet/page.tsx new file mode 100644 index 000000000..b61ae3ba2 --- /dev/null +++ b/apps/website/app/leagues/[id]/wallet/page.tsx @@ -0,0 +1,483 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams } from 'next/navigation'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import { + Wallet, + DollarSign, + ArrowUpRight, + ArrowDownLeft, + Clock, + AlertTriangle, + CheckCircle, + XCircle, + Download, + CreditCard, + TrendingUp, + Calendar +} from 'lucide-react'; + +interface Transaction { + id: string; + type: 'sponsorship' | 'membership' | 'withdrawal' | 'prize'; + description: string; + amount: number; + fee: number; + netAmount: number; + date: Date; + status: 'completed' | 'pending' | 'failed'; + reference?: string; +} + +interface WalletData { + balance: number; + currency: string; + totalRevenue: number; + totalFees: number; + totalWithdrawals: number; + pendingPayouts: number; + transactions: Transaction[]; + canWithdraw: boolean; + withdrawalBlockReason?: string; +} + +// Mock data for demonstration +const MOCK_WALLET: WalletData = { + balance: 2450.00, + currency: 'USD', + totalRevenue: 3200.00, + totalFees: 320.00, + totalWithdrawals: 430.00, + pendingPayouts: 150.00, + canWithdraw: false, + withdrawalBlockReason: 'Season 2 is still active. Withdrawals are available after season completion.', + transactions: [ + { + id: 'txn-1', + type: 'sponsorship', + description: 'Main Sponsor - TechCorp', + amount: 1200.00, + fee: 120.00, + netAmount: 1080.00, + date: new Date('2025-12-01'), + status: 'completed', + reference: 'SP-2025-001', + }, + { + id: 'txn-2', + type: 'sponsorship', + description: 'Secondary Sponsor - RaceFuel', + amount: 400.00, + fee: 40.00, + netAmount: 360.00, + date: new Date('2025-12-01'), + status: 'completed', + reference: 'SP-2025-002', + }, + { + id: 'txn-3', + type: 'membership', + description: 'Season Fee - 32 drivers', + amount: 1600.00, + fee: 160.00, + netAmount: 1440.00, + date: new Date('2025-11-15'), + status: 'completed', + reference: 'MF-2025-032', + }, + { + id: 'txn-4', + type: 'withdrawal', + description: 'Bank Transfer - Season 1 Payout', + amount: -430.00, + fee: 0, + netAmount: -430.00, + date: new Date('2025-10-30'), + status: 'completed', + reference: 'WD-2025-001', + }, + { + id: 'txn-5', + type: 'prize', + description: 'Championship Prize Pool (reserved)', + amount: -150.00, + fee: 0, + netAmount: -150.00, + date: new Date('2025-12-05'), + status: 'pending', + reference: 'PZ-2025-001', + }, + ], +}; + +function TransactionRow({ transaction }: { transaction: Transaction }) { + const isIncoming = transaction.amount > 0; + + const typeIcons = { + sponsorship: DollarSign, + membership: CreditCard, + withdrawal: ArrowUpRight, + prize: TrendingUp, + }; + const TypeIcon = typeIcons[transaction.type]; + + const statusConfig = { + completed: { color: 'text-performance-green', bg: 'bg-performance-green/10', icon: CheckCircle }, + pending: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', icon: Clock }, + failed: { color: 'text-racing-red', bg: 'bg-racing-red/10', icon: XCircle }, + }; + const status = statusConfig[transaction.status]; + const StatusIcon = status.icon; + + return ( +
+
+
+ {isIncoming ? ( + + ) : ( + + )} +
+
+
+ {transaction.description} + + {transaction.status} + +
+
+ + {transaction.type} + {transaction.reference && ( + <> + + {transaction.reference} + + )} + + {transaction.date.toLocaleDateString()} +
+
+
+
+
+ {isIncoming ? '+' : ''}{transaction.amount < 0 ? '-' : ''}${Math.abs(transaction.amount).toFixed(2)} +
+ {transaction.fee > 0 && ( +
+ Fee: ${transaction.fee.toFixed(2)} +
+ )} +
+
+ ); +} + +export default function LeagueWalletPage() { + const params = useParams(); + const [wallet, setWallet] = useState(MOCK_WALLET); + const [withdrawAmount, setWithdrawAmount] = useState(''); + const [showWithdrawModal, setShowWithdrawModal] = useState(false); + const [processing, setProcessing] = useState(false); + const [filterType, setFilterType] = useState<'all' | 'sponsorship' | 'membership' | 'withdrawal' | 'prize'>('all'); + + const filteredTransactions = wallet.transactions.filter( + t => filterType === 'all' || t.type === filterType + ); + + const handleWithdraw = async () => { + if (!withdrawAmount || parseFloat(withdrawAmount) <= 0) return; + + setProcessing(true); + try { + const response = await fetch(`/api/wallets/${params.id}/withdraw`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + amount: parseFloat(withdrawAmount), + currency: wallet.currency, + seasonId: 'season-2', // Current active season + destinationAccount: 'bank-account-***1234', + }), + }); + + const result = await response.json(); + + if (!response.ok) { + alert(result.reason || result.error || 'Withdrawal failed'); + return; + } + + alert(`Withdrawal of $${withdrawAmount} processed successfully!`); + setShowWithdrawModal(false); + setWithdrawAmount(''); + // Refresh wallet data + } catch (err) { + console.error('Withdrawal error:', err); + alert('Failed to process withdrawal'); + } finally { + setProcessing(false); + } + }; + + return ( +
+ {/* Header */} +
+
+

League Wallet

+

Manage your league's finances and payouts

+
+
+ + +
+
+ + {/* Withdrawal Warning */} + {!wallet.canWithdraw && wallet.withdrawalBlockReason && ( +
+
+ +
+

Withdrawals Temporarily Unavailable

+

{wallet.withdrawalBlockReason}

+
+
+
+ )} + + {/* Stats Grid */} +
+ +
+
+ +
+
+
${wallet.balance.toFixed(2)}
+
Available Balance
+
+
+
+ + +
+
+ +
+
+
${wallet.totalRevenue.toFixed(2)}
+
Total Revenue
+
+
+
+ + +
+
+ +
+
+
${wallet.totalFees.toFixed(2)}
+
Platform Fees (10%)
+
+
+
+ + +
+
+ +
+
+
${wallet.pendingPayouts.toFixed(2)}
+
Pending Payouts
+
+
+
+
+ + {/* Transactions */} + +
+

Transaction History

+ +
+ + {filteredTransactions.length === 0 ? ( +
+ +

No Transactions

+

+ {filterType === 'all' + ? 'Revenue from sponsorships and fees will appear here.' + : `No ${filterType} transactions found.`} +

+
+ ) : ( +
+ {filteredTransactions.map((transaction) => ( + + ))} +
+ )} +
+ + {/* Revenue Breakdown */} +
+ +

Revenue Breakdown

+
+
+
+
+ Sponsorships +
+ $1,600.00 +
+
+
+
+ Membership Fees +
+ $1,600.00 +
+
+ Total Gross Revenue + $3,200.00 +
+
+ Platform Fee (10%) + -$320.00 +
+
+ Net Revenue + $2,880.00 +
+
+ + + +

Payout Schedule

+
+
+
+ Season 2 Prize Pool + Pending +
+

+ Distributed after season completion to top 3 drivers +

+
+
+
+ Available for Withdrawal + ${wallet.balance.toFixed(2)} +
+

+ Available after Season 2 ends (estimated: Jan 15, 2026) +

+
+
+
+
+ + {/* Withdraw Modal */} + {showWithdrawModal && ( +
+ +

Withdraw Funds

+ + {!wallet.canWithdraw ? ( +
+

{wallet.withdrawalBlockReason}

+
+ ) : ( + <> +
+ +
+ $ + setWithdrawAmount(e.target.value)} + max={wallet.balance} + className="w-full pl-8 pr-4 py-2 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none" + placeholder="0.00" + /> +
+

+ Available: ${wallet.balance.toFixed(2)} +

+
+ +
+ + +
+ + )} + +
+ + +
+
+
+ )} + + {/* Alpha Notice */} +
+

+ Alpha Note: Wallet management is demonstration-only. + Real payment processing and bank integrations will be available when the payment system is fully implemented. + The 10% platform fee and season-based withdrawal restrictions are enforced in the actual implementation. +

+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/leagues/page.tsx b/apps/website/app/leagues/page.tsx index 2472f8e6e..9ca25aad0 100644 --- a/apps/website/app/leagues/page.tsx +++ b/apps/website/app/leagues/page.tsx @@ -65,361 +65,6 @@ interface Category { // DEMO LEAGUES DATA // ============================================================================ -const DEMO_LEAGUES: LeagueSummaryDTO[] = [ - // Driver Championships - { - id: 'demo-1', - name: 'iRacing GT3 Pro Series', - description: 'Elite GT3 competition for serious sim racers. Weekly races on iconic tracks with professional stewarding and live commentary.', - createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), // 2 days ago - ownerId: 'owner-1', - maxDrivers: 32, - usedDriverSlots: 28, - structureSummary: 'Solo • 32 drivers', - scoringPatternSummary: 'Sprint + Main • Best 8 of 10', - timingSummary: '20 min Quali • 45 min Race', - scoring: { - gameId: 'iracing', - gameName: 'iRacing', - primaryChampionshipType: 'driver', - scoringPresetId: 'sprint-main-driver', - scoringPresetName: 'Sprint + Main (Driver)', - dropPolicySummary: 'Best 8 of 10', - scoringPatternSummary: 'Sprint + Main • Best 8 of 10', - }, - }, - { - id: 'demo-2', - name: 'iRacing IMSA Championship', - description: 'Race across continents in the most prestigious GT championship. Professional-grade competition with real-world rules.', - createdAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000), - ownerId: 'owner-2', - maxDrivers: 40, - usedDriverSlots: 35, - structureSummary: 'Solo • 40 drivers', - scoringPatternSummary: 'Feature Race • Best 6 of 8', - timingSummary: '30 min Quali • 60 min Race', - scoring: { - gameId: 'iracing', - gameName: 'iRacing', - primaryChampionshipType: 'driver', - scoringPresetId: 'feature-driver', - scoringPresetName: 'Feature Race (Driver)', - dropPolicySummary: 'Best 6 of 8', - scoringPatternSummary: 'Feature Race • Best 6 of 8', - }, - }, - { - id: 'demo-3', - name: 'iRacing Formula Championship', - description: 'The ultimate open-wheel experience. Full calendar, realistic regulations, and championship-level competition.', - createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), // Yesterday - ownerId: 'owner-3', - maxDrivers: 20, - usedDriverSlots: 20, - structureSummary: 'Solo • 20 drivers', - scoringPatternSummary: 'Sprint + Feature • All rounds count', - timingSummary: '18 min Quali • 50% Race', - scoring: { - gameId: 'iracing', - gameName: 'iRacing', - primaryChampionshipType: 'driver', - scoringPresetId: 'sprint-feature-driver', - scoringPresetName: 'Sprint + Feature (Driver)', - dropPolicySummary: 'All rounds count', - scoringPatternSummary: 'Sprint + Feature • All rounds count', - }, - }, - // Team Championships - { - id: 'demo-4', - name: 'Le Mans Virtual Series', - description: 'Endurance racing at its finest. Multi-class prototype and GT competition with team strategy at the core.', - createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), - ownerId: 'owner-4', - maxDrivers: 48, - usedDriverSlots: 42, - maxTeams: 16, - usedTeamSlots: 14, - structureSummary: 'Teams • 16 × 3 drivers', - scoringPatternSummary: 'Endurance • Best 4 of 6', - timingSummary: '30 min Quali • 6h Race', - scoring: { - gameId: 'iracing', - gameName: 'iRacing', - primaryChampionshipType: 'team', - scoringPresetId: 'endurance-team', - scoringPresetName: 'Endurance (Team)', - dropPolicySummary: 'Best 4 of 6', - scoringPatternSummary: 'Endurance • Best 4 of 6', - }, - }, - { - id: 'demo-5', - name: 'iRacing British GT Teams', - description: 'British GT-style team championship. Pro-Am format with driver ratings and team strategy.', - createdAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000), - ownerId: 'owner-5', - maxDrivers: 40, - usedDriverSlots: 32, - maxTeams: 20, - usedTeamSlots: 16, - structureSummary: 'Teams • 20 × 2 drivers', - scoringPatternSummary: 'Sprint + Main • Best 8 of 10', - timingSummary: '15 min Quali • 60 min Race', - scoring: { - gameId: 'iracing', - gameName: 'iRacing', - primaryChampionshipType: 'team', - scoringPresetId: 'sprint-main-team', - scoringPresetName: 'Sprint + Main (Team)', - dropPolicySummary: 'Best 8 of 10', - scoringPatternSummary: 'Sprint + Main • Best 8 of 10', - }, - }, - // Nations Cup - { - id: 'demo-6', - name: 'FIA Nations Cup iRacing', - description: 'Represent your nation in this prestigious international competition. Pride, glory, and national anthems.', - createdAt: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000), - ownerId: 'owner-6', - maxDrivers: 50, - usedDriverSlots: 45, - structureSummary: 'Nations • 50 drivers', - scoringPatternSummary: 'Feature Race • All rounds count', - timingSummary: '20 min Quali • 40 min Race', - scoring: { - gameId: 'iracing', - gameName: 'iRacing', - primaryChampionshipType: 'nations', - scoringPresetId: 'feature-nations', - scoringPresetName: 'Feature Race (Nations)', - dropPolicySummary: 'All rounds count', - scoringPatternSummary: 'Feature Race • All rounds count', - }, - }, - { - id: 'demo-7', - name: 'European Nations GT Cup', - description: 'The best European nations battle it out in GT3 machinery. Honor your flag.', - createdAt: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000), - ownerId: 'owner-7', - maxDrivers: 30, - usedDriverSlots: 24, - structureSummary: 'Nations • 30 drivers', - scoringPatternSummary: 'Sprint + Main • Best 6 of 8', - timingSummary: '15 min Quali • 45 min Race', - scoring: { - gameId: 'iracing', - gameName: 'iRacing', - primaryChampionshipType: 'nations', - scoringPresetId: 'sprint-main-nations', - scoringPresetName: 'Sprint + Main (Nations)', - dropPolicySummary: 'Best 6 of 8', - scoringPatternSummary: 'Sprint + Main • Best 6 of 8', - }, - }, - // Trophy Series - { - id: 'demo-8', - name: 'Rookie Trophy Challenge', - description: 'Perfect for newcomers! Learn the ropes of competitive racing in a supportive environment.', - createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), // Yesterday - ownerId: 'owner-8', - maxDrivers: 24, - usedDriverSlots: 18, - structureSummary: 'Solo • 24 drivers', - scoringPatternSummary: 'Feature Race • Best 8 of 10', - timingSummary: '10 min Quali • 20 min Race', - scoring: { - gameId: 'iracing', - gameName: 'iRacing', - primaryChampionshipType: 'trophy', - scoringPresetId: 'feature-trophy', - scoringPresetName: 'Feature Race (Trophy)', - dropPolicySummary: 'Best 8 of 10', - scoringPatternSummary: 'Feature Race • Best 8 of 10', - }, - }, - { - id: 'demo-9', - name: 'Porsche Cup Masters', - description: 'One-make series featuring the iconic Porsche 911 GT3 Cup. Pure driving skill determines the winner.', - createdAt: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000), - ownerId: 'owner-9', - maxDrivers: 28, - usedDriverSlots: 26, - structureSummary: 'Solo • 28 drivers', - scoringPatternSummary: 'Sprint + Main • Best 10 of 12', - timingSummary: '15 min Quali • 30 min Race', - scoring: { - gameId: 'iracing', - gameName: 'iRacing', - primaryChampionshipType: 'trophy', - scoringPresetId: 'sprint-main-trophy', - scoringPresetName: 'Sprint + Main (Trophy)', - dropPolicySummary: 'Best 10 of 12', - scoringPatternSummary: 'Sprint + Main • Best 10 of 12', - }, - }, - // More variety - Recently Added - { - id: 'demo-10', - name: 'GT World Challenge Sprint', - description: 'Fast-paced sprint racing in GT3 machinery. Short, intense races that reward consistency.', - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000), // 12 hours ago - ownerId: 'owner-10', - maxDrivers: 36, - usedDriverSlots: 12, - structureSummary: 'Solo • 36 drivers', - scoringPatternSummary: 'Sprint Format • Best 8 of 10', - timingSummary: '10 min Quali • 25 min Race', - scoring: { - gameId: 'iracing', - gameName: 'iRacing', - primaryChampionshipType: 'driver', - scoringPresetId: 'sprint-driver', - scoringPresetName: 'Sprint (Driver)', - dropPolicySummary: 'Best 8 of 10', - scoringPatternSummary: 'Sprint Format • Best 8 of 10', - }, - }, - { - id: 'demo-11', - name: 'Nürburgring 24h League', - description: 'The ultimate test of endurance. Teams battle through day and night at the legendary Nordschleife.', - createdAt: new Date(Date.now() - 6 * 60 * 60 * 1000), // 6 hours ago - ownerId: 'owner-11', - maxDrivers: 60, - usedDriverSlots: 8, - maxTeams: 20, - usedTeamSlots: 4, - structureSummary: 'Teams • 20 × 3 drivers', - scoringPatternSummary: 'Endurance • All races count', - timingSummary: '45 min Quali • 24h Race', - scoring: { - gameId: 'iracing', - gameName: 'iRacing', - primaryChampionshipType: 'team', - scoringPresetId: 'endurance-team', - scoringPresetName: 'Endurance (Team)', - dropPolicySummary: 'All races count', - scoringPatternSummary: 'Endurance • All races count', - }, - }, - { - id: 'demo-12', - name: 'iRacing Constructors Battle', - description: 'Team-based championship. Coordinate with your teammate to maximize constructor points.', - createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago - ownerId: 'owner-12', - maxDrivers: 20, - usedDriverSlots: 6, - maxTeams: 10, - usedTeamSlots: 3, - structureSummary: 'Teams • 10 × 2 drivers', - scoringPatternSummary: 'Full Season • All rounds count', - timingSummary: '18 min Quali • 60 min Race', - scoring: { - gameId: 'iracing', - gameName: 'iRacing', - primaryChampionshipType: 'team', - scoringPresetId: 'full-season-team', - scoringPresetName: 'Full Season (Team)', - dropPolicySummary: 'All rounds count', - scoringPatternSummary: 'Full Season • All rounds count', - }, - }, - // Additional popular leagues - { - id: 'demo-13', - name: 'VRS GT Endurance Series', - description: 'Multi-class endurance racing with LMP2 and GT3. Strategic pit stops and driver changes required.', - createdAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000), - ownerId: 'owner-13', - maxDrivers: 54, - usedDriverSlots: 51, - maxTeams: 18, - usedTeamSlots: 17, - structureSummary: 'Teams • 18 × 3 drivers', - scoringPatternSummary: 'Endurance • Best 5 of 6', - timingSummary: '30 min Quali • 4h Race', - scoring: { - gameId: 'iracing', - gameName: 'iRacing', - primaryChampionshipType: 'team', - scoringPresetId: 'endurance-team', - scoringPresetName: 'Endurance (Team)', - dropPolicySummary: 'Best 5 of 6', - scoringPatternSummary: 'Endurance • Best 5 of 6', - }, - }, - { - id: 'demo-14', - name: 'Ferrari Challenge Series', - description: 'One-make Ferrari 488 Challenge championship. Italian passion meets precision racing.', - createdAt: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000), - ownerId: 'owner-14', - maxDrivers: 24, - usedDriverSlots: 22, - structureSummary: 'Solo • 24 drivers', - scoringPatternSummary: 'Sprint + Main • Best 10 of 12', - timingSummary: '15 min Quali • 35 min Race', - scoring: { - gameId: 'iracing', - gameName: 'iRacing', - primaryChampionshipType: 'trophy', - scoringPresetId: 'sprint-main-trophy', - scoringPresetName: 'Sprint + Main (Trophy)', - dropPolicySummary: 'Best 10 of 12', - scoringPatternSummary: 'Sprint + Main • Best 10 of 12', - }, - }, - { - id: 'demo-15', - name: 'Oceania Nations Cup', - description: 'Australia and New Zealand battle for Pacific supremacy in this regional nations championship.', - createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), - ownerId: 'owner-15', - maxDrivers: 20, - usedDriverSlots: 15, - structureSummary: 'Nations • 20 drivers', - scoringPatternSummary: 'Feature Race • Best 6 of 8', - timingSummary: '15 min Quali • 45 min Race', - scoring: { - gameId: 'iracing', - gameName: 'iRacing', - primaryChampionshipType: 'nations', - scoringPresetId: 'feature-nations', - scoringPresetName: 'Feature Race (Nations)', - dropPolicySummary: 'Best 6 of 8', - scoringPatternSummary: 'Feature Race • Best 6 of 8', - }, - }, - { - id: 'demo-16', - name: 'iRacing Sprint Series', - description: 'Quick 20-minute races for drivers with limited time. Maximum action, minimum commitment.', - createdAt: new Date(Date.now() - 18 * 60 * 60 * 1000), // 18 hours ago - ownerId: 'owner-16', - maxDrivers: 28, - usedDriverSlots: 14, - structureSummary: 'Solo • 28 drivers', - scoringPatternSummary: 'Sprint Only • All races count', - timingSummary: '8 min Quali • 20 min Race', - scoring: { - gameId: 'iracing', - gameName: 'iRacing', - primaryChampionshipType: 'driver', - scoringPresetId: 'sprint-driver', - scoringPresetName: 'Sprint (Driver)', - dropPolicySummary: 'All races count', - scoringPatternSummary: 'Sprint Only • All races count', - }, - }, -]; - // ============================================================================ // CATEGORIES // ============================================================================ @@ -754,14 +399,11 @@ export default function LeaguesPage() { } }; - // Combine real leagues with demo leagues - const leagues = [...realLeagues, ...DEMO_LEAGUES]; + // Use only real leagues from repository + const leagues = realLeagues; const handleLeagueClick = (leagueId: string) => { - // Don't navigate for demo leagues - if (leagueId.startsWith('demo-')) { - return; - } + // Navigate to league - all leagues are clickable router.push(`/leagues/${leagueId}`); }; diff --git a/apps/website/app/profile/liveries/page.tsx b/apps/website/app/profile/liveries/page.tsx new file mode 100644 index 000000000..2074b9d69 --- /dev/null +++ b/apps/website/app/profile/liveries/page.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { useState } from 'react'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import { Paintbrush, Upload, Car, Download, Trash2, Edit } from 'lucide-react'; +import Link from 'next/link'; + +interface DriverLiveryItem { + id: string; + carId: string; + carName: string; + thumbnailUrl: string; + uploadedAt: Date; + isValidated: boolean; +} + +export default function DriverLiveriesPage() { + const [liveries] = useState([]); + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

My Liveries

+

Manage your car liveries across leagues

+
+
+ + + +
+ + {/* Livery Collection */} + {liveries.length === 0 ? ( + +
+
+ +
+

No Liveries Yet

+

+ Upload your first livery. Use the same livery across multiple leagues or create custom ones for each. +

+ + + +
+
+ ) : ( +
+ {liveries.map((livery) => ( + + {/* Livery Preview */} +
+ +
+ + {/* Livery Info */} +
+
+

{livery.carName}

+ {livery.isValidated ? ( + + Validated + + ) : ( + + Pending + + )} +
+ +

+ Uploaded {new Date(livery.uploadedAt).toLocaleDateString()} +

+ + {/* Actions */} +
+ + + +
+
+
+ ))} +
+ )} + + {/* Info Section */} +
+ +

Livery Requirements

+
    +
  • + + PNG or DDS format, max 5MB +
  • +
  • + + No logos or text allowed on base livery +
  • +
  • + + Sponsor decals are added by league admins +
  • +
  • + + Your driver name and number are added automatically +
  • +
+
+ + +

How It Works

+
    +
  1. + 1. + Upload your base livery for each car you race +
  2. +
  3. + 2. + Position your name and number decals +
  4. +
  5. + 3. + League admins add sponsor logos +
  6. +
  7. + 4. + Download the final pack with all decals burned in +
  8. +
+
+
+ + {/* Alpha Notice */} +
+

+ Alpha Note: Livery management is demonstration-only. + In production, liveries are stored in cloud storage and composited with sponsor decals. +

+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/profile/liveries/upload/page.tsx b/apps/website/app/profile/liveries/upload/page.tsx new file mode 100644 index 000000000..5410721fc --- /dev/null +++ b/apps/website/app/profile/liveries/upload/page.tsx @@ -0,0 +1,405 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import { Upload, Paintbrush, Move, ZoomIn, Check, X, AlertTriangle, Car, RotateCw, Gamepad2 } from 'lucide-react'; + +interface DecalPosition { + id: string; + type: 'name' | 'number' | 'rank'; + x: number; + y: number; + width: number; + height: number; + rotation: number; +} + +interface GameOption { + id: string; + name: string; +} + +interface CarOption { + id: string; + name: string; + manufacturer: string; + gameId: string; +} + +// Mock data - in production these would come from API +const GAMES: GameOption[] = [ + { id: 'iracing', name: 'iRacing' }, + { id: 'acc', name: 'Assetto Corsa Competizione' }, + { id: 'ac', name: 'Assetto Corsa' }, + { id: 'rf2', name: 'rFactor 2' }, + { id: 'ams2', name: 'Automobilista 2' }, + { id: 'lmu', name: 'Le Mans Ultimate' }, +]; + +const CARS: CarOption[] = [ + // iRacing cars + { id: 'ir-porsche-911-gt3r', name: '911 GT3 R', manufacturer: 'Porsche', gameId: 'iracing' }, + { id: 'ir-ferrari-296-gt3', name: '296 GT3', manufacturer: 'Ferrari', gameId: 'iracing' }, + { id: 'ir-bmw-m4-gt3', name: 'M4 GT3', manufacturer: 'BMW', gameId: 'iracing' }, + { id: 'ir-mercedes-amg-gt3', name: 'AMG GT3 Evo', manufacturer: 'Mercedes-AMG', gameId: 'iracing' }, + { id: 'ir-audi-r8-gt3', name: 'R8 LMS GT3 Evo II', manufacturer: 'Audi', gameId: 'iracing' }, + { id: 'ir-dallara-f3', name: 'F3', manufacturer: 'Dallara', gameId: 'iracing' }, + { id: 'ir-dallara-ir18', name: 'IR-18', manufacturer: 'Dallara', gameId: 'iracing' }, + // ACC cars + { id: 'acc-porsche-911-gt3r', name: '911 GT3 R', manufacturer: 'Porsche', gameId: 'acc' }, + { id: 'acc-ferrari-296-gt3', name: '296 GT3', manufacturer: 'Ferrari', gameId: 'acc' }, + { id: 'acc-bmw-m4-gt3', name: 'M4 GT3', manufacturer: 'BMW', gameId: 'acc' }, + { id: 'acc-mercedes-amg-gt3', name: 'AMG GT3 Evo', manufacturer: 'Mercedes-AMG', gameId: 'acc' }, + { id: 'acc-lamborghini-huracan-gt3', name: 'Huracán GT3 Evo2', manufacturer: 'Lamborghini', gameId: 'acc' }, + { id: 'acc-aston-martin-v8-gt3', name: 'V8 Vantage GT3', manufacturer: 'Aston Martin', gameId: 'acc' }, + // AC cars + { id: 'ac-porsche-911-gt3r', name: '911 GT3 R', manufacturer: 'Porsche', gameId: 'ac' }, + { id: 'ac-ferrari-488-gt3', name: '488 GT3', manufacturer: 'Ferrari', gameId: 'ac' }, + { id: 'ac-lotus-exos', name: 'Exos 125', manufacturer: 'Lotus', gameId: 'ac' }, + // rFactor 2 cars + { id: 'rf2-porsche-911-gt3r', name: '911 GT3 R', manufacturer: 'Porsche', gameId: 'rf2' }, + { id: 'rf2-bmw-m4-gt3', name: 'M4 GT3', manufacturer: 'BMW', gameId: 'rf2' }, + // AMS2 cars + { id: 'ams2-porsche-911-gt3r', name: '911 GT3 R', manufacturer: 'Porsche', gameId: 'ams2' }, + { id: 'ams2-mclaren-720s-gt3', name: '720S GT3', manufacturer: 'McLaren', gameId: 'ams2' }, + // LMU cars + { id: 'lmu-porsche-963', name: '963 LMDh', manufacturer: 'Porsche', gameId: 'lmu' }, + { id: 'lmu-ferrari-499p', name: '499P', manufacturer: 'Ferrari', gameId: 'lmu' }, + { id: 'lmu-toyota-gr010', name: 'GR010', manufacturer: 'Toyota', gameId: 'lmu' }, +]; + +export default function LiveryUploadPage() { + const router = useRouter(); + const fileInputRef = useRef(null); + const [uploadedFile, setUploadedFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [selectedGame, setSelectedGame] = useState(''); + const [selectedCar, setSelectedCar] = useState(''); + const [filteredCars, setFilteredCars] = useState([]); + const [decals, setDecals] = useState([ + { id: 'name', type: 'name', x: 0.1, y: 0.8, width: 0.2, height: 0.05, rotation: 0 }, + { id: 'number', type: 'number', x: 0.8, y: 0.1, width: 0.15, height: 0.15, rotation: 0 }, + { id: 'rank', type: 'rank', x: 0.05, y: 0.1, width: 0.1, height: 0.1, rotation: 0 }, + ]); + const [activeDecal, setActiveDecal] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // Filter cars when game changes + useEffect(() => { + if (selectedGame) { + const cars = CARS.filter(car => car.gameId === selectedGame); + setFilteredCars(cars); + setSelectedCar(''); // Reset car selection when game changes + } else { + setFilteredCars([]); + setSelectedCar(''); + } + }, [selectedGame]); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setUploadedFile(file); + const url = URL.createObjectURL(file); + setPreviewUrl(url); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer.files?.[0]; + if (file) { + setUploadedFile(file); + const url = URL.createObjectURL(file); + setPreviewUrl(url); + } + }; + + const handleSubmit = async () => { + if (!uploadedFile || !selectedGame || !selectedCar) return; + + setSubmitting(true); + + try { + // Alpha: In-memory only + console.log('Livery upload:', { + file: uploadedFile.name, + gameId: selectedGame, + carId: selectedCar, + decals, + }); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + alert('Livery uploaded successfully.'); + router.push('/profile/liveries'); + } catch (err) { + console.error('Upload failed:', err); + alert('Upload failed. Try again.'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Upload Livery

+

Add a new livery to your collection

+
+
+
+ +
+ {/* Upload Section */} + +

Livery File

+ + {/* Game Selection */} +
+ + +
+ + {/* Car Selection */} +
+ + + {selectedGame && filteredCars.length === 0 && ( +

No cars available for this game

+ )} +
+ + {/* File Upload */} +
fileInputRef.current?.click()} + onDrop={handleDrop} + onDragOver={(e) => e.preventDefault()} + className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${ + previewUrl + ? 'border-performance-green/50 bg-performance-green/5' + : 'border-charcoal-outline hover:border-primary-blue/50 hover:bg-primary-blue/5' + }`} + > + + + {previewUrl ? ( +
+ +

{uploadedFile?.name}

+

Click to replace

+
+ ) : ( +
+ +

+ Drop your livery here or click to browse +

+

PNG or DDS, max 5MB

+
+ )} +
+ + {/* Validation Warning */} +
+
+ +

+ No logos or text allowed.{' '} + Your base livery must be clean. Sponsor logos are added by league admins. +

+
+
+
+ + {/* Decal Editor */} + +

Position Decals

+

+ Drag to position your driver name, number, and rank badge. +

+ + {/* Preview Canvas */} +
+ {previewUrl ? ( + Livery preview + ) : ( +
+ +
+ )} + + {/* Decal Placeholders */} + {decals.map((decal) => ( +
setActiveDecal(decal.id === activeDecal ? null : decal.id)} + className={`absolute cursor-move border-2 rounded flex items-center justify-center text-xs font-medium transition-all ${ + activeDecal === decal.id + ? 'border-primary-blue bg-primary-blue/20 text-primary-blue' + : 'border-white/30 bg-black/30 text-white/70' + }`} + style={{ + left: `${decal.x * 100}%`, + top: `${decal.y * 100}%`, + width: `${decal.width * 100}%`, + height: `${decal.height * 100}%`, + transform: `rotate(${decal.rotation}deg)`, + }} + > + {decal.type === 'name' && 'NAME'} + {decal.type === 'number' && '#'} + {decal.type === 'rank' && 'RANK'} +
+ ))} +
+ + {/* Decal Controls */} +
+ {decals.map((decal) => ( + + ))} +
+ + {/* Rotation Controls */} + {activeDecal && ( +
+
+ + {decals.find(d => d.id === activeDecal)?.type} Rotation + + + {decals.find(d => d.id === activeDecal)?.rotation}° + +
+
+ d.id === activeDecal)?.rotation ?? 0} + onChange={(e) => { + const rotation = parseInt(e.target.value, 10); + setDecals(decals.map(d => + d.id === activeDecal ? { ...d, rotation } : d + )); + }} + className="flex-1 h-2 bg-charcoal-outline rounded-lg appearance-none cursor-pointer accent-primary-blue" + /> + +
+
+ )} + +

+ Click a decal above, then drag on preview to reposition. Use the slider or button to rotate. +

+
+
+ + {/* Actions */} +
+ + +
+ + {/* Alpha Notice */} +
+

+ Alpha Note: Livery upload is demonstration-only. + Decal positioning and image validation are not functional in this preview. +

+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/profile/sponsorship-requests/page.tsx b/apps/website/app/profile/sponsorship-requests/page.tsx new file mode 100644 index 000000000..f10996ea7 --- /dev/null +++ b/apps/website/app/profile/sponsorship-requests/page.tsx @@ -0,0 +1,303 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import Breadcrumbs from '@/components/layout/Breadcrumbs'; +import PendingSponsorshipRequests, { type PendingRequestDTO } from '@/components/sponsors/PendingSponsorshipRequests'; +import { + getGetPendingSponsorshipRequestsQuery, + getAcceptSponsorshipRequestUseCase, + getRejectSponsorshipRequestUseCase, + getDriverRepository, + getLeagueRepository, + getTeamRepository, + getLeagueMembershipRepository, + getTeamMembershipRepository, +} from '@/lib/di-container'; +import { useEffectiveDriverId } from '@/lib/currentDriver'; +import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; +import { Handshake, User, Users, Trophy, ChevronRight, Building, AlertTriangle } from 'lucide-react'; +import Link from 'next/link'; + +interface EntitySection { + entityType: 'driver' | 'team' | 'race' | 'season'; + entityId: string; + entityName: string; + requests: PendingRequestDTO[]; +} + +export default function SponsorshipRequestsPage() { + const router = useRouter(); + const currentDriverId = useEffectiveDriverId(); + + const [sections, setSections] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadAllRequests = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const driverRepo = getDriverRepository(); + const leagueRepo = getLeagueRepository(); + const teamRepo = getTeamRepository(); + const leagueMembershipRepo = getLeagueMembershipRepository(); + const teamMembershipRepo = getTeamMembershipRepository(); + const query = getGetPendingSponsorshipRequestsQuery(); + + const allSections: EntitySection[] = []; + + // 1. Driver's own sponsorship requests + const driverResult = await query.execute({ + entityType: 'driver', + entityId: currentDriverId, + }); + + if (driverResult.requests.length > 0) { + const driver = await driverRepo.findById(currentDriverId); + allSections.push({ + entityType: 'driver', + entityId: currentDriverId, + entityName: driver?.name ?? 'Your Profile', + requests: driverResult.requests, + }); + } + + // 2. Leagues where the user is admin/owner + const allLeagues = await leagueRepo.findAll(); + for (const league of allLeagues) { + const membership = await leagueMembershipRepo.getMembership(league.id, currentDriverId); + if (membership && isLeagueAdminOrHigherRole(membership.role)) { + // Load sponsorship requests for this league's active season + try { + // For simplicity, we'll query by season entityType - in production you'd get the active season ID + const leagueResult = await query.execute({ + entityType: 'season', + entityId: league.id, // Using league ID as a proxy for now + }); + + if (leagueResult.requests.length > 0) { + allSections.push({ + entityType: 'season', + entityId: league.id, + entityName: league.name, + requests: leagueResult.requests, + }); + } + } catch (err) { + // Silently skip if no requests found + } + } + } + + // 3. Teams where the user is owner/manager + const allTeams = await teamRepo.findAll(); + for (const team of allTeams) { + const membership = await teamMembershipRepo.getMembership(team.id, currentDriverId); + if (membership && (membership.role === 'owner' || membership.role === 'manager')) { + const teamResult = await query.execute({ + entityType: 'team', + entityId: team.id, + }); + + if (teamResult.requests.length > 0) { + allSections.push({ + entityType: 'team', + entityId: team.id, + entityName: team.name, + requests: teamResult.requests, + }); + } + } + } + + setSections(allSections); + } catch (err) { + console.error('Failed to load sponsorship requests:', err); + setError(err instanceof Error ? err.message : 'Failed to load requests'); + } finally { + setLoading(false); + } + }, [currentDriverId]); + + useEffect(() => { + loadAllRequests(); + }, [loadAllRequests]); + + const handleAccept = async (requestId: string) => { + const useCase = getAcceptSponsorshipRequestUseCase(); + await useCase.execute({ + requestId, + respondedBy: currentDriverId, + }); + await loadAllRequests(); + }; + + const handleReject = async (requestId: string, reason?: string) => { + const useCase = getRejectSponsorshipRequestUseCase(); + await useCase.execute({ + requestId, + respondedBy: currentDriverId, + reason, + }); + await loadAllRequests(); + }; + + 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 '#'; + } + }; + + const totalRequests = sections.reduce((sum, s) => sum + s.requests.length, 0); + + return ( +
+ + + {/* Header */} +
+
+ +
+
+

Sponsorship Requests

+

+ Manage sponsorship requests for your profile, teams, and leagues +

+
+ {totalRequests > 0 && ( +
+ {totalRequests} pending +
+ )} +
+ + {loading ? ( + +
+
Loading sponsorship requests...
+
+
+ ) : error ? ( + +
+
+ +
+

Error Loading Requests

+

{error}

+ +
+
+ ) : sections.length === 0 ? ( + +
+
+ +
+

No Pending Requests

+

+ You don't have any pending sponsorship requests at the moment. +

+

+ Sponsors can apply to sponsor your profile, teams, or leagues you manage. +

+
+
+ ) : ( +
+ {sections.map((section) => { + const Icon = getEntityIcon(section.entityType); + const entityLink = getEntityLink(section.entityType, section.entityId); + + return ( + + {/* Section Header */} +
+
+
+ +
+
+

{section.entityName}

+

{section.entityType}

+
+
+ + View {section.entityType === 'season' ? 'Sponsorships' : section.entityType} + + +
+ + {/* Requests */} + +
+ ); + })} +
+ )} + + {/* Info Card */} + +
+
+ +
+
+

How Sponsorships Work

+

+ 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. +

+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/races/[id]/page.tsx b/apps/website/app/races/[id]/page.tsx index fc9d6a662..de3c91bfe 100644 --- a/apps/website/app/races/[id]/page.tsx +++ b/apps/website/app/races/[id]/page.tsx @@ -8,6 +8,7 @@ import Card from '@/components/ui/Card'; import Heading from '@/components/ui/Heading'; import Breadcrumbs from '@/components/layout/Breadcrumbs'; import FileProtestModal from '@/components/races/FileProtestModal'; +import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard'; import type { Race } from '@gridpilot/racing/domain/entities/Race'; import type { League } from '@gridpilot/racing/domain/entities/League'; import type { Driver } from '@gridpilot/racing/domain/entities/Driver'; @@ -73,6 +74,7 @@ export default function RaceDetailPage() { const [showProtestModal, setShowProtestModal] = useState(false); const currentDriverId = useEffectiveDriverId(); + const isSponsorMode = useSponsorMode(); const loadRaceData = async () => { try { @@ -408,6 +410,21 @@ export default function RaceDetailPage() { return { rating: stats.rating, rank }; }; + // Build sponsor insights for race + const sponsorInsights = { + tier: 'gold' as const, + trustScore: 92, + discordMembers: league ? 1847 : undefined, + monthlyActivity: 156, + }; + + const raceMetrics = [ + MetricBuilders.views(entryList.length * 12), + MetricBuilders.engagement(78), + { label: 'SOF', value: raceSOF?.toString() ?? '—', icon: Zap, color: 'text-warning-amber' as const }, + MetricBuilders.reach(entryList.length * 45), + ]; + return (
@@ -424,6 +441,20 @@ export default function RaceDetailPage() {
+ {/* Sponsor Insights Card - Consistent placement at top */} + {isSponsorMode && race && league && ( + + )} + {/* User Result - Premium Achievement Card */} {userResult && (
{cancelling ? 'Cancelling...' : 'Cancel Race'} - )} -
- - - {/* Status Info */} + )} +
+
+ + {/* Status Info */}
diff --git a/apps/website/app/sponsor/billing/page.tsx b/apps/website/app/sponsor/billing/page.tsx new file mode 100644 index 000000000..21884db33 --- /dev/null +++ b/apps/website/app/sponsor/billing/page.tsx @@ -0,0 +1,267 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import { + CreditCard, + DollarSign, + Calendar, + Download, + Plus, + Check, + AlertTriangle, + FileText, + ArrowRight +} from 'lucide-react'; + +interface PaymentMethod { + id: string; + type: 'card' | 'bank'; + last4: string; + brand?: string; + isDefault: boolean; + expiryMonth?: number; + expiryYear?: number; +} + +interface Invoice { + id: string; + date: Date; + amount: number; + status: 'paid' | 'pending' | 'failed'; + description: string; +} + +// Mock data +const MOCK_PAYMENT_METHODS: PaymentMethod[] = [ + { + id: 'pm-1', + type: 'card', + last4: '4242', + brand: 'Visa', + isDefault: true, + expiryMonth: 12, + expiryYear: 2027, + }, + { + id: 'pm-2', + type: 'card', + last4: '5555', + brand: 'Mastercard', + isDefault: false, + expiryMonth: 6, + expiryYear: 2026, + }, +]; + +const MOCK_INVOICES: Invoice[] = [ + { + id: 'inv-1', + date: new Date('2025-11-01'), + amount: 1200, + status: 'paid', + description: 'GT3 Pro Championship - Main Sponsor (Q4 2025)', + }, + { + id: 'inv-2', + date: new Date('2025-10-01'), + amount: 400, + status: 'paid', + description: 'Formula Sim Series - Secondary Sponsor (Q4 2025)', + }, + { + id: 'inv-3', + date: new Date('2025-12-01'), + amount: 350, + status: 'pending', + description: 'Touring Car Cup - Secondary Sponsor (Q1 2026)', + }, +]; + +function PaymentMethodCard({ method, onSetDefault }: { method: PaymentMethod; onSetDefault: () => void }) { + return ( +
+
+
+
+ +
+
+
+ {method.brand} •••• {method.last4} + {method.isDefault && ( + Default + )} +
+ {method.expiryMonth && method.expiryYear && ( + Expires {method.expiryMonth}/{method.expiryYear} + )} +
+
+
+ {!method.isDefault && ( + + )} + +
+
+
+ ); +} + +function InvoiceRow({ invoice }: { invoice: Invoice }) { + const statusConfig = { + paid: { icon: Check, color: 'text-performance-green', bg: 'bg-performance-green/10' }, + pending: { icon: AlertTriangle, color: 'text-warning-amber', bg: 'bg-warning-amber/10' }, + failed: { icon: AlertTriangle, color: 'text-racing-red', bg: 'bg-racing-red/10' }, + }; + + const status = statusConfig[invoice.status]; + const StatusIcon = status.icon; + + return ( +
+
+
+ +
+
+
{invoice.description}
+
+ {invoice.date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })} +
+
+
+
+
+
${invoice.amount.toLocaleString()}
+
+ + {invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)} +
+
+ +
+
+ ); +} + +export default function SponsorBillingPage() { + const router = useRouter(); + const [paymentMethods, setPaymentMethods] = useState(MOCK_PAYMENT_METHODS); + + const handleSetDefault = (methodId: string) => { + setPaymentMethods(methods => + methods.map(m => ({ ...m, isDefault: m.id === methodId })) + ); + }; + + const totalSpent = MOCK_INVOICES.filter(i => i.status === 'paid').reduce((sum, i) => sum + i.amount, 0); + const pendingAmount = MOCK_INVOICES.filter(i => i.status === 'pending').reduce((sum, i) => sum + i.amount, 0); + + return ( +
+ {/* Header */} +
+

+ + Billing & Payments +

+

Manage payment methods and view invoices

+
+ + {/* Summary Cards */} +
+ +
+ + Total Spent +
+
${totalSpent.toLocaleString()}
+
+ +
+ + Pending +
+
${pendingAmount.toLocaleString()}
+
+ +
+ + Next Payment +
+
Dec 15, 2025
+
+
+ + {/* Payment Methods */} + +
+

Payment Methods

+ +
+
+ {paymentMethods.map((method) => ( + handleSetDefault(method.id)} + /> + ))} +
+
+ + {/* Billing History */} + +
+

Billing History

+ +
+
+ {MOCK_INVOICES.map((invoice) => ( + + ))} +
+
+ +
+
+ + {/* Platform Fee Notice */} +
+

Platform Fee

+

+ A 10% platform fee is applied to all sponsorship payments. This fee helps maintain the platform, + provide analytics, and ensure quality sponsorship placements. +

+
+ + {/* Alpha Notice */} +
+

+ Alpha Note: Payment processing is demonstration-only. + Real billing will be available when the payment system is fully implemented. +

+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/sponsor/campaigns/page.tsx b/apps/website/app/sponsor/campaigns/page.tsx new file mode 100644 index 000000000..d1b055f06 --- /dev/null +++ b/apps/website/app/sponsor/campaigns/page.tsx @@ -0,0 +1,278 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import { + Megaphone, + Trophy, + Users, + Eye, + Calendar, + ExternalLink, + Plus, + ChevronRight, + Check, + Clock, + XCircle +} from 'lucide-react'; + +interface Sponsorship { + id: string; + leagueId: string; + leagueName: string; + tier: 'main' | 'secondary'; + status: 'active' | 'pending' | 'expired'; + startDate: Date; + endDate: Date; + price: number; + impressions: number; + drivers: number; +} + +// Mock data - in production would come from repository +const MOCK_SPONSORSHIPS: Sponsorship[] = [ + { + id: 'sp-1', + leagueId: 'league-1', + leagueName: 'GT3 Pro Championship', + tier: 'main', + status: 'active', + startDate: new Date('2025-01-01'), + endDate: new Date('2025-06-30'), + price: 1200, + impressions: 45200, + drivers: 32, + }, + { + id: 'sp-2', + leagueId: 'league-2', + leagueName: 'Endurance Masters', + tier: 'main', + status: 'active', + startDate: new Date('2025-02-01'), + endDate: new Date('2025-07-31'), + price: 1000, + impressions: 38100, + drivers: 48, + }, + { + id: 'sp-3', + leagueId: 'league-3', + leagueName: 'Formula Sim Series', + tier: 'secondary', + status: 'active', + startDate: new Date('2025-03-01'), + endDate: new Date('2025-08-31'), + price: 400, + impressions: 22800, + drivers: 24, + }, + { + id: 'sp-4', + leagueId: 'league-4', + leagueName: 'Touring Car Cup', + tier: 'secondary', + status: 'pending', + startDate: new Date('2025-04-01'), + endDate: new Date('2025-09-30'), + price: 350, + impressions: 0, + drivers: 28, + }, +]; + +function SponsorshipCard({ sponsorship }: { sponsorship: Sponsorship }) { + const router = useRouter(); + + const statusConfig = { + active: { icon: Check, color: 'text-performance-green', bg: 'bg-performance-green/10', label: 'Active' }, + pending: { icon: Clock, color: 'text-warning-amber', bg: 'bg-warning-amber/10', label: 'Pending' }, + expired: { icon: XCircle, color: 'text-gray-400', bg: 'bg-gray-400/10', label: 'Expired' }, + }; + + const tierConfig = { + main: { color: 'text-primary-blue', bg: 'bg-primary-blue/10', border: 'border-primary-blue/30', label: 'Main Sponsor' }, + secondary: { color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/30', label: 'Secondary' }, + }; + + const status = statusConfig[sponsorship.status]; + const tier = tierConfig[sponsorship.tier]; + const StatusIcon = status.icon; + + return ( + +
+
+
+ {tier.label} +
+
+ + {status.label} +
+
+ +
+ +

{sponsorship.leagueName}

+ +
+
+
+ + Impressions +
+
{sponsorship.impressions.toLocaleString()}
+
+
+
+ + Drivers +
+
{sponsorship.drivers}
+
+
+
+ + Period +
+
+ {sponsorship.startDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} - {sponsorship.endDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} +
+
+
+
+ + Investment +
+
${sponsorship.price}
+
+
+ +
+ + {Math.ceil((sponsorship.endDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24))} days remaining + + +
+
+ ); +} + +export default function SponsorCampaignsPage() { + const router = useRouter(); + const [filter, setFilter] = useState<'all' | 'active' | 'pending' | 'expired'>('all'); + + const filteredSponsorships = filter === 'all' + ? MOCK_SPONSORSHIPS + : MOCK_SPONSORSHIPS.filter(s => s.status === filter); + + const stats = { + total: MOCK_SPONSORSHIPS.length, + active: MOCK_SPONSORSHIPS.filter(s => s.status === 'active').length, + pending: MOCK_SPONSORSHIPS.filter(s => s.status === 'pending').length, + totalInvestment: MOCK_SPONSORSHIPS.reduce((sum, s) => sum + s.price, 0), + }; + + return ( +
+ {/* Header */} +
+
+

+ + My Sponsorships +

+

Manage your league sponsorships

+
+ +
+ + {/* Stats */} +
+ +
{stats.total}
+
Total Sponsorships
+
+ +
{stats.active}
+
Active
+
+ +
{stats.pending}
+
Pending
+
+ +
${stats.totalInvestment.toLocaleString()}
+
Total Investment
+
+
+ + {/* Filters */} +
+ {(['all', 'active', 'pending', 'expired'] as const).map((f) => ( + + ))} +
+ + {/* Sponsorship List */} + {filteredSponsorships.length === 0 ? ( + + +

No sponsorships found

+

Start sponsoring leagues to grow your brand visibility

+ +
+ ) : ( +
+ {filteredSponsorships.map((sponsorship) => ( + + ))} +
+ )} + + {/* Alpha Notice */} +
+

+ Alpha Note: Sponsorship data shown here is demonstration-only. + Real sponsorship management will be available when the system is fully implemented. +

+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/sponsor/dashboard/page.tsx b/apps/website/app/sponsor/dashboard/page.tsx new file mode 100644 index 000000000..24867668f --- /dev/null +++ b/apps/website/app/sponsor/dashboard/page.tsx @@ -0,0 +1,404 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import { + BarChart3, + Eye, + Users, + Trophy, + TrendingUp, + Calendar, + DollarSign, + Target, + ArrowUpRight, + ArrowDownRight, + ExternalLink, + Loader2 +} from 'lucide-react'; +import Link from 'next/link'; + +interface SponsorshipMetrics { + impressions: number; + impressionsChange: number; + uniqueViewers: number; + viewersChange: number; + races: number; + drivers: number; + exposure: number; + exposureChange: number; +} + +interface SponsoredLeague { + id: string; + name: string; + tier: 'main' | 'secondary'; + drivers: number; + races: number; + impressions: number; + status: 'active' | 'upcoming' | 'completed'; +} + +interface SponsorDashboardData { + sponsorId: string; + sponsorName: string; + metrics: SponsorshipMetrics; + sponsoredLeagues: SponsoredLeague[]; + investment: { + activeSponsorships: number; + totalInvestment: number; + costPerThousandViews: number; + }; +} + +// Fallback mock data for demo mode +const MOCK_DASHBOARD: SponsorDashboardData = { + sponsorId: 'demo-sponsor', + sponsorName: 'Demo Sponsor', + metrics: { + impressions: 124500, + impressionsChange: 12.5, + uniqueViewers: 8420, + viewersChange: 8.3, + races: 24, + drivers: 156, + exposure: 87.5, + exposureChange: 5.2, + }, + sponsoredLeagues: [ + { + id: 'league-1', + name: 'GT3 Pro Championship', + tier: 'main', + drivers: 32, + races: 12, + impressions: 45200, + status: 'active', + }, + { + id: 'league-2', + name: 'Endurance Masters', + tier: 'main', + drivers: 48, + races: 6, + impressions: 38100, + status: 'active', + }, + { + id: 'league-3', + name: 'Formula Sim Series', + tier: 'secondary', + drivers: 24, + races: 8, + impressions: 22800, + status: 'active', + }, + { + id: 'league-4', + name: 'Touring Car Cup', + tier: 'secondary', + drivers: 28, + races: 10, + impressions: 18400, + status: 'upcoming', + }, + ], + investment: { + activeSponsorships: 4, + totalInvestment: 2400, + costPerThousandViews: 19.28, + }, +}; + +function MetricCard({ + title, + value, + change, + icon: Icon, + suffix = '', +}: { + title: string; + value: number | string; + change?: number; + icon: typeof Eye; + suffix?: string; +}) { + const isPositive = change && change > 0; + const isNegative = change && change < 0; + + return ( + +
+
+ +
+ {change !== undefined && ( +
+ {isPositive ? : isNegative ? : null} + {Math.abs(change)}% +
+ )} +
+
+ {typeof value === 'number' ? value.toLocaleString() : value}{suffix} +
+
{title}
+
+ ); +} + +function LeagueRow({ league }: { league: SponsoredLeague }) { + const statusColors = { + active: 'bg-performance-green/20 text-performance-green', + upcoming: 'bg-warning-amber/20 text-warning-amber', + completed: 'bg-gray-500/20 text-gray-400', + }; + + const tierColors = { + main: 'bg-primary-blue/20 text-primary-blue border-primary-blue/30', + secondary: 'bg-purple-500/20 text-purple-400 border-purple-500/30', + }; + + return ( +
+
+
+ {league.tier === 'main' ? 'Main Sponsor' : 'Secondary'} +
+
+
{league.name}
+
+ {league.drivers} drivers • {league.races} races +
+
+
+
+
+
{league.impressions.toLocaleString()}
+
impressions
+
+
+ {league.status} +
+ + + +
+
+ ); +} + +export default function SponsorDashboardPage() { + const [timeRange, setTimeRange] = useState<'7d' | '30d' | '90d' | 'all'>('30d'); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchDashboard() { + try { + const response = await fetch('/api/sponsors/dashboard'); + if (response.ok) { + const dashboardData = await response.json(); + setData(dashboardData); + } else { + // Use mock data for demo mode + setData(MOCK_DASHBOARD); + } + } catch { + // Use mock data on error + setData(MOCK_DASHBOARD); + } finally { + setLoading(false); + } + } + + fetchDashboard(); + }, []); + + if (loading) { + return ( +
+ +
+ ); + } + + const dashboardData = data || MOCK_DASHBOARD; + + return ( +
+ {/* Header */} +
+
+

Sponsor Dashboard

+

Track your sponsorship performance and exposure

+
+
+ {(['7d', '30d', '90d', 'all'] as const).map((range) => ( + + ))} +
+
+ + {/* Metrics Grid */} +
+ + + + +
+ +
+ {/* Sponsored Leagues */} +
+ +
+

Sponsored Leagues

+ + + +
+
+ {dashboardData.sponsoredLeagues.length > 0 ? ( + dashboardData.sponsoredLeagues.map((league) => ( + + )) + ) : ( +
+

No active sponsorships yet.

+ + Browse leagues to sponsor + +
+ )} +
+
+
+ + {/* Quick Stats & Actions */} +
+ {/* Investment Summary */} + +

Investment Summary

+
+
+ Active Sponsorships + {dashboardData.investment.activeSponsorships} +
+
+ Total Investment + ${dashboardData.investment.totalInvestment.toLocaleString()} +
+
+ Cost per 1K Views + ${dashboardData.investment.costPerThousandViews.toFixed(2)} +
+
+ Next Payment + Dec 15, 2025 +
+
+
+ + {/* Recent Activity */} + +

Recent Activity

+
+
+
+
+

GT3 Pro Championship race completed

+

2 hours ago • 1,240 views

+
+
+
+
+
+

New driver joined Endurance Masters

+

5 hours ago

+
+
+
+
+
+

Touring Car Cup season starting soon

+

1 day ago

+
+
+
+ + + {/* Quick Actions */} + +

Quick Actions

+
+ + + + + + + +
+
+
+
+ + {/* Alpha Notice */} +
+

+ Alpha Note: Sponsor analytics data shown here is demonstration-only. + Real analytics will be available when the sponsorship system is fully implemented. +

+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/sponsor/leagues/[id]/page.tsx b/apps/website/app/sponsor/leagues/[id]/page.tsx new file mode 100644 index 000000000..30ca8f6af --- /dev/null +++ b/apps/website/app/sponsor/leagues/[id]/page.tsx @@ -0,0 +1,311 @@ +'use client'; + +import { useState } from 'react'; +import { useParams } from 'next/navigation'; +import Link from 'next/link'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import { + Trophy, + Users, + Calendar, + Eye, + TrendingUp, + Download, + Image as ImageIcon, + ExternalLink, + ChevronRight +} from 'lucide-react'; + +interface LeagueDriver { + id: string; + name: string; + country: string; + position: number; + races: number; + impressions: number; +} + +// Mock data +const MOCK_LEAGUE = { + id: 'league-1', + name: 'GT3 Pro Championship', + tier: 'main' as const, + season: 'Season 3', + drivers: 32, + races: 12, + completedRaces: 8, + impressions: 45200, + avgViewsPerRace: 5650, + logoPlacement: 'Primary hood placement + League page banner', + status: 'active' as const, +}; + +const MOCK_DRIVERS: LeagueDriver[] = [ + { id: 'd1', name: 'Max Verstappen', country: 'NL', position: 1, races: 8, impressions: 4200 }, + { id: 'd2', name: 'Lewis Hamilton', country: 'GB', position: 2, races: 8, impressions: 3980 }, + { id: 'd3', name: 'Charles Leclerc', country: 'MC', position: 3, races: 8, impressions: 3750 }, + { id: 'd4', name: 'Lando Norris', country: 'GB', position: 4, races: 7, impressions: 3420 }, + { id: 'd5', name: 'Carlos Sainz', country: 'ES', position: 5, races: 8, impressions: 3100 }, +]; + +const MOCK_RACES = [ + { id: 'r1', name: 'Spa-Francorchamps', date: '2025-12-01', views: 6200, status: 'completed' }, + { id: 'r2', name: 'Monza', date: '2025-12-08', views: 5800, status: 'completed' }, + { id: 'r3', name: 'Nürburgring', date: '2025-12-15', views: 0, status: 'upcoming' }, + { id: 'r4', name: 'Suzuka', date: '2025-12-22', views: 0, status: 'upcoming' }, +]; + +export default function SponsorLeagueDetailPage() { + const params = useParams(); + const [activeTab, setActiveTab] = useState<'overview' | 'drivers' | 'races' | 'assets'>('overview'); + + return ( +
+ {/* Breadcrumb */} +
+ Dashboard + + Leagues + + {MOCK_LEAGUE.name} +
+ + {/* Header */} +
+
+
+

{MOCK_LEAGUE.name}

+ + Main Sponsor + +
+

{MOCK_LEAGUE.season} • {MOCK_LEAGUE.completedRaces}/{MOCK_LEAGUE.races} races completed

+
+
+ + +
+
+ + {/* Quick Stats */} +
+ +
+
+ +
+
+
{MOCK_LEAGUE.impressions.toLocaleString()}
+
Total Impressions
+
+
+
+ +
+
+ +
+
+
{MOCK_LEAGUE.avgViewsPerRace.toLocaleString()}
+
Avg Views/Race
+
+
+
+ +
+
+ +
+
+
{MOCK_LEAGUE.drivers}
+
Active Drivers
+
+
+
+ +
+
+ +
+
+
{MOCK_LEAGUE.races - MOCK_LEAGUE.completedRaces}
+
Races Remaining
+
+
+
+
+ + {/* Tabs */} +
+ {(['overview', 'drivers', 'races', 'assets'] as const).map((tab) => ( + + ))} +
+ + {/* Tab Content */} + {activeTab === 'overview' && ( +
+ +

Sponsorship Details

+
+
+ Tier + Main Sponsor +
+
+ Logo Placement + {MOCK_LEAGUE.logoPlacement} +
+
+ Season Duration + Oct 2025 - Feb 2026 +
+
+ Investment + $800/season +
+
+
+ + +

Performance Metrics

+
+
+ Cost per 1K Impressions + $17.70 +
+
+ Engagement Rate + 4.2% +
+
+ Brand Recall Score + 78/100 +
+
+ ROI Estimate + +24% +
+
+
+
+ )} + + {activeTab === 'drivers' && ( + +
+

Drivers Carrying Your Brand

+

Top performing drivers with your sponsorship

+
+
+ {MOCK_DRIVERS.map((driver) => ( +
+
+
+ {driver.position} +
+
+
{driver.name}
+
{driver.country} • {driver.races} races
+
+
+
+
{driver.impressions.toLocaleString()}
+
impressions
+
+
+ ))} +
+
+ )} + + {activeTab === 'races' && ( + +
+

Race Schedule & Performance

+
+
+ {MOCK_RACES.map((race) => ( +
+
+
+
+
{race.name}
+
{race.date}
+
+
+
+ {race.status === 'completed' ? ( +
+
{race.views.toLocaleString()}
+
views
+
+ ) : ( + Upcoming + )} +
+
+ ))} +
+ + )} + + {activeTab === 'assets' && ( +
+ +

Your Logo Assets

+
+
+ +
+
+ + +
+
+
+ + +

Livery Preview

+
+
+ +
+

+ Your logo appears on the primary hood position for all 32 drivers in this league. +

+ +
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/website/app/sponsor/leagues/page.tsx b/apps/website/app/sponsor/leagues/page.tsx new file mode 100644 index 000000000..bedfa63f4 --- /dev/null +++ b/apps/website/app/sponsor/leagues/page.tsx @@ -0,0 +1,298 @@ +'use client'; + +import { useState } from 'react'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import { + Trophy, + Users, + Eye, + Search, + Filter, + Star, + ChevronRight +} 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'; +} + +const MOCK_AVAILABLE_LEAGUES: AvailableLeague[] = [ + { + id: 'league-1', + name: 'GT3 Masters Championship', + game: 'iRacing', + drivers: 48, + avgViewsPerRace: 8200, + mainSponsorSlot: { available: true, price: 1200 }, + secondarySlots: { available: 1, total: 2, price: 400 }, + rating: 4.8, + tier: 'premium', + }, + { + id: 'league-2', + name: 'Endurance Pro Series', + game: 'ACC', + drivers: 72, + avgViewsPerRace: 12500, + mainSponsorSlot: { available: false, price: 1500 }, + secondarySlots: { available: 2, total: 2, price: 500 }, + rating: 4.9, + tier: 'premium', + }, + { + id: 'league-3', + name: 'Formula Sim League', + game: 'iRacing', + drivers: 24, + avgViewsPerRace: 5400, + mainSponsorSlot: { available: true, price: 800 }, + secondarySlots: { available: 2, total: 2, price: 300 }, + rating: 4.5, + tier: 'standard', + }, + { + id: 'league-4', + name: 'Touring Car Masters', + game: 'rFactor 2', + drivers: 32, + avgViewsPerRace: 3200, + mainSponsorSlot: { available: true, price: 500 }, + secondarySlots: { available: 2, total: 2, price: 200 }, + rating: 4.2, + tier: 'starter', + }, + { + id: 'league-5', + name: 'LMP Challenge', + game: 'Le Mans Ultimate', + drivers: 36, + avgViewsPerRace: 6800, + mainSponsorSlot: { available: true, price: 900 }, + secondarySlots: { available: 1, total: 2, price: 350 }, + rating: 4.6, + tier: 'standard', + }, +]; + +function LeagueCard({ league }: { league: AvailableLeague }) { + const tierColors = { + premium: 'bg-gradient-to-r from-yellow-500/20 to-amber-500/20 border-yellow-500/30', + standard: 'bg-gradient-to-r from-blue-500/20 to-cyan-500/20 border-blue-500/30', + starter: 'bg-gradient-to-r from-gray-500/20 to-slate-500/20 border-gray-500/30', + }; + + const tierBadgeColors = { + premium: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', + standard: 'bg-blue-500/20 text-blue-400 border-blue-500/30', + starter: 'bg-gray-500/20 text-gray-400 border-gray-500/30', + }; + + return ( + +
+
+
+
+

{league.name}

+ + {league.tier} + +
+

{league.game}

+
+
+ + {league.rating} +
+
+ +
+
+
{league.drivers}
+
Drivers
+
+
+
{(league.avgViewsPerRace / 1000).toFixed(1)}k
+
Avg Views
+
+
+
${(league.mainSponsorSlot.price / league.avgViewsPerRace * 1000).toFixed(0)}
+
CPM
+
+
+ +
+
+
+
+ Main Sponsor +
+
+ {league.mainSponsorSlot.available ? ( + ${league.mainSponsorSlot.price}/season + ) : ( + Taken + )} +
+
+
+
+
0 ? 'bg-performance-green' : 'bg-racing-red'}`} /> + Secondary Slots +
+
+ {league.secondarySlots.available > 0 ? ( + {league.secondarySlots.available}/{league.secondarySlots.total} @ ${league.secondarySlots.price} + ) : ( + Full + )} +
+
+
+ +
+ + {(league.mainSponsorSlot.available || league.secondarySlots.available > 0) && ( + + )} +
+
+ + ); +} + +export default function SponsorLeaguesPage() { + const [searchQuery, setSearchQuery] = useState(''); + const [tierFilter, setTierFilter] = useState<'all' | 'premium' | 'standard' | 'starter'>('all'); + const [availabilityFilter, setAvailabilityFilter] = useState<'all' | 'main' | 'secondary'>('all'); + + const filteredLeagues = MOCK_AVAILABLE_LEAGUES.filter(league => { + 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; + }); + + return ( +
+ {/* Breadcrumb */} +
+ Dashboard + + Browse Leagues +
+ + {/* Header */} +
+

Find Leagues to Sponsor

+

Discover racing leagues looking for sponsors and grow your brand

+
+ + {/* Filters */} +
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2 rounded-lg border border-charcoal-outline bg-iron-gray text-white placeholder-gray-500 focus:border-primary-blue focus:outline-none" + /> +
+
+ + +
+
+ + {/* Stats Banner */} +
+ +
{MOCK_AVAILABLE_LEAGUES.length}
+
Available Leagues
+
+ +
+ {MOCK_AVAILABLE_LEAGUES.filter(l => l.mainSponsorSlot.available).length} +
+
Main Slots Open
+
+ +
+ {MOCK_AVAILABLE_LEAGUES.reduce((sum, l) => sum + l.secondarySlots.available, 0)} +
+
Secondary Slots Open
+
+ +
+ {MOCK_AVAILABLE_LEAGUES.reduce((sum, l) => sum + l.drivers, 0)} +
+
Total Drivers
+
+
+ + {/* League Grid */} +
+ {filteredLeagues.map((league) => ( + + ))} +
+ + {filteredLeagues.length === 0 && ( +
+ +

No leagues found

+

Try adjusting your filters to see more results

+
+ )} + + {/* Alpha Notice */} +
+

+ Alpha Note: League sponsorship marketplace is demonstration-only. + Actual sponsorship purchases will be available when the payment system is fully implemented. +

+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/sponsor/page.tsx b/apps/website/app/sponsor/page.tsx new file mode 100644 index 000000000..8d27a1487 --- /dev/null +++ b/apps/website/app/sponsor/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function SponsorPage() { + redirect('/sponsor/dashboard'); +} \ No newline at end of file diff --git a/apps/website/app/sponsor/settings/page.tsx b/apps/website/app/sponsor/settings/page.tsx new file mode 100644 index 000000000..7012f2ce7 --- /dev/null +++ b/apps/website/app/sponsor/settings/page.tsx @@ -0,0 +1,333 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import Input from '@/components/ui/Input'; +import { + Settings, + Building2, + Mail, + Globe, + Upload, + Save, + Bell, + Shield, + Eye, + Trash2 +} from 'lucide-react'; + +interface SponsorProfile { + name: string; + email: string; + website: string; + description: string; + logoUrl: string | null; +} + +interface NotificationSettings { + emailNewSponsorships: boolean; + emailWeeklyReport: boolean; + emailRaceAlerts: boolean; + emailPaymentAlerts: boolean; +} + +// Mock data +const MOCK_PROFILE: SponsorProfile = { + name: 'Acme Racing Co.', + email: 'sponsor@acme-racing.com', + website: 'https://acme-racing.com', + description: 'Premium sim racing equipment and accessories for competitive drivers.', + logoUrl: null, +}; + +const MOCK_NOTIFICATIONS: NotificationSettings = { + emailNewSponsorships: true, + emailWeeklyReport: true, + emailRaceAlerts: false, + emailPaymentAlerts: true, +}; + +function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (checked: boolean) => void; label: string }) { + return ( + + ); +} + +export default function SponsorSettingsPage() { + const router = useRouter(); + const [profile, setProfile] = useState(MOCK_PROFILE); + const [notifications, setNotifications] = useState(MOCK_NOTIFICATIONS); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + + const handleSaveProfile = async () => { + setSaving(true); + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 800)); + setSaving(false); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + }; + + const handleDeleteAccount = () => { + if (confirm('Are you sure you want to delete your sponsor account? This action cannot be undone.')) { + // Clear demo cookies and redirect + document.cookie = 'gridpilot_demo_mode=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'; + document.cookie = 'gridpilot_sponsor_id=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'; + router.push('/'); + } + }; + + return ( +
+ {/* Header */} +
+

+ + Sponsor Settings +

+

Manage your sponsor profile and preferences

+
+ + {/* Company Profile */} + +
+

+ + Company Profile +

+
+
+
+ + setProfile({ ...profile, name: e.target.value })} + placeholder="Your company name" + /> +
+ +
+ + setProfile({ ...profile, email: e.target.value })} + placeholder="sponsor@company.com" + /> +
+ +
+ + setProfile({ ...profile, website: e.target.value })} + placeholder="https://company.com" + /> +
+ +
+ +