This commit is contained in:
2025-12-10 12:38:55 +01:00
parent 0f7fe67d3c
commit fbbcf414a4
87 changed files with 11972 additions and 390 deletions

View File

@@ -0,0 +1,171 @@
import { NextRequest, NextResponse } from 'next/server';
// Alpha: In-memory membership fee storage
const membershipFees: Map<string, {
id: string;
leagueId: string;
seasonId?: string;
type: 'season' | 'monthly' | 'per_race';
amount: number;
enabled: boolean;
createdAt: Date;
updatedAt: Date;
}> = new Map();
const memberPayments: Map<string, {
id: string;
feeId: string;
driverId: string;
amount: number;
platformFee: number;
netAmount: number;
status: 'pending' | 'paid' | 'overdue';
dueDate: Date;
paidAt?: Date;
}> = 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<string, infer V> ? 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 }
);
}
}

View File

@@ -0,0 +1,180 @@
import { NextRequest, NextResponse } from 'next/server';
// Alpha: In-memory prize storage
const prizes: Map<string, {
id: string;
leagueId: string;
seasonId: string;
position: number;
name: string;
amount: number;
type: 'cash' | 'merchandise' | 'other';
description?: string;
awarded: boolean;
awardedTo?: string;
awardedAt?: Date;
createdAt: Date;
}> = 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 }
);
}
}

View File

@@ -0,0 +1,141 @@
import { NextRequest, NextResponse } from 'next/server';
// Alpha: In-memory payment storage (mock payment gateway)
const payments: Map<string, {
id: string;
type: 'sponsorship' | 'membership_fee';
amount: number;
platformFee: number;
netAmount: number;
payerId: string;
payerType: 'sponsor' | 'driver';
leagueId: string;
seasonId?: string;
status: 'pending' | 'completed' | 'failed' | 'refunded';
createdAt: Date;
completedAt?: Date;
}> = 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 }
);
}
}

View File

@@ -0,0 +1,142 @@
import { NextRequest, NextResponse } from 'next/server';
// Alpha: In-memory wallet storage
const wallets: Map<string, {
id: string;
leagueId: string;
balance: number;
totalRevenue: number;
totalPlatformFees: number;
totalWithdrawn: number;
createdAt: Date;
}> = new Map();
const transactions: Map<string, {
id: string;
walletId: string;
type: 'deposit' | 'withdrawal' | 'platform_fee';
amount: number;
description: string;
referenceId?: string;
referenceType?: 'sponsorship' | 'membership_fee' | 'prize';
createdAt: Date;
}> = 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 }
);
}
}

View File

@@ -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 });
}
}

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server';
// Alpha: In-memory sponsor storage
const sponsors: Map<string, {
id: string;
name: string;
contactEmail: string;
websiteUrl?: string;
logoUrl?: string;
createdAt: Date;
}> = 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 }
);
}
}

View File

@@ -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 });
}
}

View File

@@ -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<string, { status: 'planned' | 'active' | 'completed'; name: string }> = {
'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<string, { balance: number; currency: string }> = {
'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 }
);
}
}

View File

@@ -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 (
<div className="max-w-6xl mx-auto px-4 pb-12 space-y-6">
{/* Back Navigation */}
@@ -478,6 +491,20 @@ export default function DriverDetailPage({
]}
/>
{/* Sponsor Insights Card - Consistent placement at top */}
{isSponsorMode && driver && (
<SponsorInsightsCard
entityType="driver"
entityId={driver.id}
entityName={driver.name}
tier="standard"
metrics={driverMetrics}
slots={SlotTemplates.driver(true, 200)}
trustScore={88}
monthlyActivity={stats?.consistency ?? 75}
/>
)}
{/* Hero Header Section */}
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-iron-gray/80 via-iron-gray/60 to-deep-graphite border border-charcoal-outline">
{/* Background Pattern */}

View File

@@ -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<League | null>(null);
const [ownerName, setOwnerName] = useState<string>('');
const [mainSponsor, setMainSponsor] = useState<MainSponsorInfo | null>(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 */}

View File

@@ -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<League | null>(null);
const [owner, setOwner] = useState<Driver | null>(null);
@@ -40,12 +60,44 @@ export default function LeagueDetailPage() {
const [scoringConfig, setScoringConfig] = useState<LeagueScoringConfigDTO | null>(null);
const [averageSOF, setAverageSOF] = useState<number | null>(null);
const [completedRacesCount, setCompletedRacesCount] = useState<number>(0);
const [sponsors, setSponsors] = useState<SponsorInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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() {
</Card>
) : (
<>
{/* Sponsor Insights Card - Only shown to sponsors, at top of page */}
{isSponsor && league && (
<SponsorInsightsCard
entityType="league"
entityId={leagueId}
entityName={league.name}
tier={sponsorInsights.tier}
metrics={leagueMetrics}
slots={SlotTemplates.league(
sponsorInsights.mainSponsorAvailable,
sponsorInsights.secondarySlotsAvailable,
sponsorInsights.mainSponsorPrice,
sponsorInsights.secondaryPrice
)}
trustScore={sponsorInsights.trustScore}
discordMembers={sponsorInsights.discordMembers}
monthlyActivity={sponsorInsights.monthlyActivity}
additionalStats={{
label: 'League Stats',
items: [
{ label: 'Total Races', value: completedRacesCount },
{ label: 'Active Members', value: leagueMemberships.length },
{ label: 'Total Impressions', value: sponsorInsights.totalImpressions },
],
}}
/>
)}
{/* Action Card */}
{!membership && (
{!membership && !isSponsor && (
<Card className="mb-6">
<div className="flex items-center justify-between">
<div>
@@ -288,6 +411,102 @@ export default function LeagueDetailPage() {
)}
</Card>
{/* Sponsors Section - Show sponsor logos */}
{sponsors.length > 0 && (
<Card>
<h3 className="text-lg font-semibold text-white mb-4">
{sponsors.find(s => s.tier === 'main') ? 'Presented by' : 'Sponsors'}
</h3>
<div className="space-y-3">
{/* Main Sponsor - Featured prominently */}
{sponsors.filter(s => s.tier === 'main').map(sponsor => (
<div
key={sponsor.id}
className="p-3 rounded-lg bg-gradient-to-r from-yellow-500/10 to-transparent border border-yellow-500/30"
>
<div className="flex items-center gap-3">
{sponsor.logoUrl ? (
<div className="w-12 h-12 rounded-lg bg-white flex items-center justify-center overflow-hidden">
<img
src={sponsor.logoUrl}
alt={sponsor.name}
className="w-10 h-10 object-contain"
/>
</div>
) : (
<div className="w-12 h-12 rounded-lg bg-yellow-500/20 flex items-center justify-center">
<Trophy className="w-6 h-6 text-yellow-400" />
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-white truncate">{sponsor.name}</span>
<span className="px-1.5 py-0.5 rounded text-[10px] bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
Main
</span>
</div>
{sponsor.tagline && (
<p className="text-xs text-gray-400 truncate mt-0.5">{sponsor.tagline}</p>
)}
</div>
{sponsor.websiteUrl && (
<a
href={sponsor.websiteUrl}
target="_blank"
rel="noreferrer"
className="p-1.5 rounded-lg bg-iron-gray hover:bg-charcoal-outline transition-colors"
>
<ExternalLink className="w-4 h-4 text-gray-400" />
</a>
)}
</div>
</div>
))}
{/* Secondary Sponsors - Smaller display */}
{sponsors.filter(s => s.tier === 'secondary').length > 0 && (
<div className="grid grid-cols-2 gap-2">
{sponsors.filter(s => s.tier === 'secondary').map(sponsor => (
<div
key={sponsor.id}
className="p-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline"
>
<div className="flex items-center gap-2">
{sponsor.logoUrl ? (
<div className="w-8 h-8 rounded bg-white flex items-center justify-center overflow-hidden flex-shrink-0">
<img
src={sponsor.logoUrl}
alt={sponsor.name}
className="w-6 h-6 object-contain"
/>
</div>
) : (
<div className="w-8 h-8 rounded bg-purple-500/20 flex items-center justify-center flex-shrink-0">
<Star className="w-4 h-4 text-purple-400" />
</div>
)}
<div className="flex-1 min-w-0">
<span className="text-sm text-white truncate block">{sponsor.name}</span>
</div>
{sponsor.websiteUrl && (
<a
href={sponsor.websiteUrl}
target="_blank"
rel="noreferrer"
className="p-1 rounded hover:bg-charcoal-outline transition-colors"
>
<ExternalLink className="w-3 h-3 text-gray-500" />
</a>
)}
</div>
</div>
))}
</div>
)}
</div>
</Card>
)}
{/* Management */}
{(ownerMembership || adminMemberships.length > 0 || stewardMemberships.length > 0) && (
<Card>

View File

@@ -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<League | null>(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 (
<Card>
<div className="py-6 text-sm text-gray-400 text-center">Loading sponsorships...</div>
</Card>
);
}
if (!isAdmin) {
return (
<Card>
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
<AlertTriangle className="w-8 h-8 text-warning-amber" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
<p className="text-sm text-gray-400">
Only league admins can manage sponsorships.
</p>
</div>
</Card>
);
}
if (!league) {
return (
<Card>
<div className="py-6 text-sm text-gray-500 text-center">
League not found.
</div>
</Card>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
<Building className="w-6 h-6 text-primary-blue" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Sponsorships</h1>
<p className="text-sm text-gray-400">Manage sponsorship slots and review requests</p>
</div>
</div>
{/* Sponsorships Section */}
<Card>
<LeagueSponsorshipsSection
leagueId={leagueId}
readOnly={false}
/>
</Card>
</div>
);
}

View File

@@ -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 (
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline last:border-b-0 hover:bg-iron-gray/30 transition-colors">
<div className="flex items-center gap-4">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${isIncoming ? 'bg-performance-green/10' : 'bg-iron-gray/50'}`}>
{isIncoming ? (
<ArrowDownLeft className="w-5 h-5 text-performance-green" />
) : (
<ArrowUpRight className="w-5 h-5 text-gray-400" />
)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-white">{transaction.description}</span>
<span className={`px-2 py-0.5 rounded text-xs ${status.bg} ${status.color}`}>
{transaction.status}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500 mt-1">
<TypeIcon className="w-3 h-3" />
<span className="capitalize">{transaction.type}</span>
{transaction.reference && (
<>
<span></span>
<span>{transaction.reference}</span>
</>
)}
<span></span>
<span>{transaction.date.toLocaleDateString()}</span>
</div>
</div>
</div>
<div className="text-right">
<div className={`font-semibold ${isIncoming ? 'text-performance-green' : 'text-white'}`}>
{isIncoming ? '+' : ''}{transaction.amount < 0 ? '-' : ''}${Math.abs(transaction.amount).toFixed(2)}
</div>
{transaction.fee > 0 && (
<div className="text-xs text-gray-500">
Fee: ${transaction.fee.toFixed(2)}
</div>
)}
</div>
</div>
);
}
export default function LeagueWalletPage() {
const params = useParams();
const [wallet, setWallet] = useState<WalletData>(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 (
<div className="max-w-6xl mx-auto py-8 px-4">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">League Wallet</h1>
<p className="text-gray-400">Manage your league's finances and payouts</p>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary">
<Download className="w-4 h-4 mr-2" />
Export
</Button>
<Button
variant="primary"
onClick={() => setShowWithdrawModal(true)}
disabled={!wallet.canWithdraw}
>
<ArrowUpRight className="w-4 h-4 mr-2" />
Withdraw
</Button>
</div>
</div>
{/* Withdrawal Warning */}
{!wallet.canWithdraw && wallet.withdrawalBlockReason && (
<div className="mb-6 p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
<div>
<h3 className="font-medium text-warning-amber">Withdrawals Temporarily Unavailable</h3>
<p className="text-sm text-gray-400 mt-1">{wallet.withdrawalBlockReason}</p>
</div>
</div>
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-performance-green/10">
<Wallet className="w-6 h-6 text-performance-green" />
</div>
<div>
<div className="text-2xl font-bold text-white">${wallet.balance.toFixed(2)}</div>
<div className="text-sm text-gray-400">Available Balance</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
<TrendingUp className="w-6 h-6 text-primary-blue" />
</div>
<div>
<div className="text-2xl font-bold text-white">${wallet.totalRevenue.toFixed(2)}</div>
<div className="text-sm text-gray-400">Total Revenue</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-warning-amber/10">
<DollarSign className="w-6 h-6 text-warning-amber" />
</div>
<div>
<div className="text-2xl font-bold text-white">${wallet.totalFees.toFixed(2)}</div>
<div className="text-sm text-gray-400">Platform Fees (10%)</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-500/10">
<Clock className="w-6 h-6 text-purple-400" />
</div>
<div>
<div className="text-2xl font-bold text-white">${wallet.pendingPayouts.toFixed(2)}</div>
<div className="text-sm text-gray-400">Pending Payouts</div>
</div>
</div>
</Card>
</div>
{/* Transactions */}
<Card>
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline">
<h2 className="text-lg font-semibold text-white">Transaction History</h2>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value as typeof filterType)}
className="px-3 py-1.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white text-sm focus:border-primary-blue focus:outline-none"
>
<option value="all">All Transactions</option>
<option value="sponsorship">Sponsorships</option>
<option value="membership">Memberships</option>
<option value="withdrawal">Withdrawals</option>
<option value="prize">Prizes</option>
</select>
</div>
{filteredTransactions.length === 0 ? (
<div className="text-center py-12">
<Wallet className="w-12 h-12 text-gray-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-white mb-2">No Transactions</h3>
<p className="text-gray-400">
{filterType === 'all'
? 'Revenue from sponsorships and fees will appear here.'
: `No ${filterType} transactions found.`}
</p>
</div>
) : (
<div>
{filteredTransactions.map((transaction) => (
<TransactionRow key={transaction.id} transaction={transaction} />
))}
</div>
)}
</Card>
{/* Revenue Breakdown */}
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Revenue Breakdown</h3>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-primary-blue" />
<span className="text-gray-400">Sponsorships</span>
</div>
<span className="font-medium text-white">$1,600.00</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-performance-green" />
<span className="text-gray-400">Membership Fees</span>
</div>
<span className="font-medium text-white">$1,600.00</span>
</div>
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<span className="text-gray-300 font-medium">Total Gross Revenue</span>
<span className="font-bold text-white">$3,200.00</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-warning-amber">Platform Fee (10%)</span>
<span className="text-warning-amber">-$320.00</span>
</div>
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<span className="text-performance-green font-medium">Net Revenue</span>
<span className="font-bold text-performance-green">$2,880.00</span>
</div>
</div>
</Card>
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Payout Schedule</h3>
<div className="space-y-3">
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-white">Season 2 Prize Pool</span>
<span className="text-sm font-medium text-warning-amber">Pending</span>
</div>
<p className="text-xs text-gray-500">
Distributed after season completion to top 3 drivers
</p>
</div>
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-white">Available for Withdrawal</span>
<span className="text-sm font-medium text-performance-green">${wallet.balance.toFixed(2)}</span>
</div>
<p className="text-xs text-gray-500">
Available after Season 2 ends (estimated: Jan 15, 2026)
</p>
</div>
</div>
</Card>
</div>
{/* Withdraw Modal */}
{showWithdrawModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Card className="w-full max-w-md p-6">
<h2 className="text-xl font-semibold text-white mb-4">Withdraw Funds</h2>
{!wallet.canWithdraw ? (
<div className="p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30 mb-4">
<p className="text-sm text-warning-amber">{wallet.withdrawalBlockReason}</p>
</div>
) : (
<>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
Amount to Withdraw
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
value={withdrawAmount}
onChange={(e) => 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"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
Available: ${wallet.balance.toFixed(2)}
</p>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
Destination
</label>
<select className="w-full px-3 py-2 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none">
<option>Bank Account ***1234</option>
</select>
</div>
</>
)}
<div className="flex gap-3 mt-6">
<Button
variant="secondary"
onClick={() => setShowWithdrawModal(false)}
className="flex-1"
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleWithdraw}
disabled={!wallet.canWithdraw || processing || !withdrawAmount}
className="flex-1"
>
{processing ? 'Processing...' : 'Withdraw'}
</Button>
</div>
</Card>
</div>
)}
{/* Alpha Notice */}
<div className="mt-6 rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> 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.
</p>
</div>
</div>
);
}

View File

@@ -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}`);
};

View File

@@ -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<DriverLiveryItem[]>([]);
return (
<div className="max-w-4xl mx-auto py-12">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
<Paintbrush className="w-6 h-6 text-primary-blue" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">My Liveries</h1>
<p className="text-sm text-gray-400">Manage your car liveries across leagues</p>
</div>
</div>
<Link href="/profile/liveries/upload">
<Button variant="primary">
<Upload className="w-4 h-4 mr-2" />
Upload Livery
</Button>
</Link>
</div>
{/* Livery Collection */}
{liveries.length === 0 ? (
<Card>
<div className="text-center py-16">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-iron-gray/50 flex items-center justify-center">
<Car className="w-10 h-10 text-gray-500" />
</div>
<h3 className="text-xl font-medium text-white mb-3">No Liveries Yet</h3>
<p className="text-sm text-gray-400 max-w-md mx-auto mb-6">
Upload your first livery. Use the same livery across multiple leagues or create custom ones for each.
</p>
<Link href="/profile/liveries/upload">
<Button variant="primary">
<Upload className="w-4 h-4 mr-2" />
Upload Your First Livery
</Button>
</Link>
</div>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{liveries.map((livery) => (
<Card key={livery.id} className="overflow-hidden hover:border-primary-blue/50 transition-colors">
{/* Livery Preview */}
<div className="aspect-video bg-deep-graphite rounded-lg mb-4 flex items-center justify-center border border-charcoal-outline">
<Car className="w-16 h-16 text-gray-600" />
</div>
{/* Livery Info */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-white">{livery.carName}</h3>
{livery.isValidated ? (
<span className="px-2 py-0.5 text-xs bg-performance-green/10 text-performance-green border border-performance-green/30 rounded-full">
Validated
</span>
) : (
<span className="px-2 py-0.5 text-xs bg-warning-amber/10 text-warning-amber border border-warning-amber/30 rounded-full">
Pending
</span>
)}
</div>
<p className="text-xs text-gray-500">
Uploaded {new Date(livery.uploadedAt).toLocaleDateString()}
</p>
{/* Actions */}
<div className="flex gap-2 pt-2">
<Button variant="secondary" className="flex-1 px-3 py-1.5">
<Edit className="w-4 h-4 mr-1" />
Edit
</Button>
<Button variant="secondary" className="px-3 py-1.5">
<Download className="w-4 h-4" />
</Button>
<Button variant="danger" className="px-3 py-1.5">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
))}
</div>
)}
{/* Info Section */}
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<h3 className="text-lg font-semibold text-white mb-3">Livery Requirements</h3>
<ul className="space-y-2 text-sm text-gray-400">
<li className="flex items-start gap-2">
<span className="text-primary-blue mt-0.5"></span>
PNG or DDS format, max 5MB
</li>
<li className="flex items-start gap-2">
<span className="text-primary-blue mt-0.5"></span>
No logos or text allowed on base livery
</li>
<li className="flex items-start gap-2">
<span className="text-primary-blue mt-0.5"></span>
Sponsor decals are added by league admins
</li>
<li className="flex items-start gap-2">
<span className="text-primary-blue mt-0.5"></span>
Your driver name and number are added automatically
</li>
</ul>
</Card>
<Card>
<h3 className="text-lg font-semibold text-white mb-3">How It Works</h3>
<ol className="space-y-2 text-sm text-gray-400">
<li className="flex items-start gap-2">
<span className="text-primary-blue font-semibold">1.</span>
Upload your base livery for each car you race
</li>
<li className="flex items-start gap-2">
<span className="text-primary-blue font-semibold">2.</span>
Position your name and number decals
</li>
<li className="flex items-start gap-2">
<span className="text-primary-blue font-semibold">3.</span>
League admins add sponsor logos
</li>
<li className="flex items-start gap-2">
<span className="text-primary-blue font-semibold">4.</span>
Download the final pack with all decals burned in
</li>
</ol>
</Card>
</div>
{/* Alpha Notice */}
<div className="mt-6 rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> Livery management is demonstration-only.
In production, liveries are stored in cloud storage and composited with sponsor decals.
</p>
</div>
</div>
);
}

View File

@@ -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<HTMLInputElement>(null);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [selectedGame, setSelectedGame] = useState<string>('');
const [selectedCar, setSelectedCar] = useState<string>('');
const [filteredCars, setFilteredCars] = useState<CarOption[]>([]);
const [decals, setDecals] = useState<DecalPosition[]>([
{ 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<string | null>(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<HTMLInputElement>) => {
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 (
<div className="max-w-4xl mx-auto py-12">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
<Upload className="w-6 h-6 text-primary-blue" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Upload Livery</h1>
<p className="text-sm text-gray-400">Add a new livery to your collection</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Upload Section */}
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Livery File</h2>
{/* Game Selection */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
<div className="flex items-center gap-2">
<Gamepad2 className="w-4 h-4" />
Select Game
</div>
</label>
<select
value={selectedGame}
onChange={(e) => setSelectedGame(e.target.value)}
className="w-full rounded-lg border border-charcoal-outline bg-iron-gray px-3 py-2 text-sm text-white focus:border-primary-blue focus:outline-none"
>
<option value="">Choose a game...</option>
{GAMES.map((game) => (
<option key={game.id} value={game.id}>
{game.name}
</option>
))}
</select>
</div>
{/* Car Selection */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-300 mb-2">
<div className="flex items-center gap-2">
<Car className="w-4 h-4" />
Select Car
</div>
</label>
<select
value={selectedCar}
onChange={(e) => setSelectedCar(e.target.value)}
disabled={!selectedGame}
className="w-full rounded-lg border border-charcoal-outline bg-iron-gray px-3 py-2 text-sm text-white focus:border-primary-blue focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">{selectedGame ? 'Choose a car...' : 'Select a game first...'}</option>
{filteredCars.map((car) => (
<option key={car.id} value={car.id}>
{car.manufacturer} {car.name}
</option>
))}
</select>
{selectedGame && filteredCars.length === 0 && (
<p className="text-xs text-gray-500 mt-1">No cars available for this game</p>
)}
</div>
{/* File Upload */}
<div
onClick={() => 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'
}`}
>
<input
ref={fileInputRef}
type="file"
accept=".png,.dds"
onChange={handleFileChange}
className="hidden"
/>
{previewUrl ? (
<div className="space-y-3">
<Check className="w-12 h-12 text-performance-green mx-auto" />
<p className="text-sm text-white font-medium">{uploadedFile?.name}</p>
<p className="text-xs text-gray-500">Click to replace</p>
</div>
) : (
<div className="space-y-3">
<Upload className="w-12 h-12 text-gray-500 mx-auto" />
<p className="text-sm text-gray-400">
Drop your livery here or click to browse
</p>
<p className="text-xs text-gray-500">PNG or DDS, max 5MB</p>
</div>
)}
</div>
{/* Validation Warning */}
<div className="mt-4 p-3 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-warning-amber shrink-0 mt-0.5" />
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">No logos or text allowed.</strong>{' '}
Your base livery must be clean. Sponsor logos are added by league admins.
</p>
</div>
</div>
</Card>
{/* Decal Editor */}
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Position Decals</h2>
<p className="text-sm text-gray-400 mb-4">
Drag to position your driver name, number, and rank badge.
</p>
{/* Preview Canvas */}
<div className="relative aspect-video bg-deep-graphite rounded-lg border border-charcoal-outline overflow-hidden mb-4">
{previewUrl ? (
<img
src={previewUrl}
alt="Livery preview"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Car className="w-20 h-20 text-gray-600" />
</div>
)}
{/* Decal Placeholders */}
{decals.map((decal) => (
<div
key={decal.id}
onClick={() => 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'}
</div>
))}
</div>
{/* Decal Controls */}
<div className="grid grid-cols-3 gap-3">
{decals.map((decal) => (
<button
key={decal.id}
onClick={() => setActiveDecal(decal.id === activeDecal ? null : decal.id)}
className={`p-3 rounded-lg border text-center transition-all ${
activeDecal === decal.id
? 'border-primary-blue bg-primary-blue/10 text-primary-blue'
: 'border-charcoal-outline bg-iron-gray/30 text-gray-400 hover:border-primary-blue/50'
}`}
>
<div className="text-xs font-medium capitalize mb-1">{decal.type}</div>
<div className="text-xs text-gray-500">
{Math.round(decal.x * 100)}%, {Math.round(decal.y * 100)}%
</div>
<div className="text-xs text-gray-600">
{decal.rotation}°
</div>
</button>
))}
</div>
{/* Rotation Controls */}
{activeDecal && (
<div className="mt-4 p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-300 capitalize">
{decals.find(d => d.id === activeDecal)?.type} Rotation
</span>
<span className="text-xs text-gray-500">
{decals.find(d => d.id === activeDecal)?.rotation}°
</span>
</div>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="360"
step="15"
value={decals.find(d => 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"
/>
<button
onClick={() => {
setDecals(decals.map(d =>
d.id === activeDecal ? { ...d, rotation: (d.rotation + 90) % 360 } : d
));
}}
className="p-2 rounded-lg border border-charcoal-outline bg-iron-gray/30 hover:bg-iron-gray/50 transition-colors"
title="Rotate 90°"
>
<RotateCw className="w-4 h-4 text-gray-400" />
</button>
</div>
</div>
)}
<p className="text-xs text-gray-500 mt-4">
Click a decal above, then drag on preview to reposition. Use the slider or button to rotate.
</p>
</Card>
</div>
{/* Actions */}
<div className="mt-6 flex gap-3">
<Button
variant="primary"
onClick={handleSubmit}
disabled={!uploadedFile || !selectedGame || !selectedCar || submitting}
>
{submitting ? 'Uploading...' : 'Upload Livery'}
</Button>
<Button
variant="secondary"
onClick={() => router.back()}
disabled={submitting}
>
Cancel
</Button>
</div>
{/* Alpha Notice */}
<div className="mt-6 rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> Livery upload is demonstration-only.
Decal positioning and image validation are not functional in this preview.
</p>
</div>
</div>
);
}

View File

@@ -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<EntitySection[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="max-w-4xl mx-auto px-4 py-8">
<Breadcrumbs
items={[
{ label: 'Profile', href: '/profile' },
{ label: 'Sponsorship Requests' },
]}
/>
{/* Header */}
<div className="flex items-center gap-4 mt-6 mb-8">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-performance-green/10 border border-performance-green/30">
<Handshake className="w-7 h-7 text-performance-green" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Sponsorship Requests</h1>
<p className="text-sm text-gray-400">
Manage sponsorship requests for your profile, teams, and leagues
</p>
</div>
{totalRequests > 0 && (
<div className="ml-auto px-3 py-1 rounded-full bg-performance-green/20 text-performance-green text-sm font-semibold">
{totalRequests} pending
</div>
)}
</div>
{loading ? (
<Card>
<div className="text-center py-12 text-gray-400">
<div className="animate-pulse">Loading sponsorship requests...</div>
</div>
</Card>
) : error ? (
<Card>
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-500/10 flex items-center justify-center">
<AlertTriangle className="w-8 h-8 text-red-400" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Error Loading Requests</h3>
<p className="text-sm text-gray-400">{error}</p>
<Button variant="secondary" onClick={loadAllRequests} className="mt-4">
Try Again
</Button>
</div>
</Card>
) : sections.length === 0 ? (
<Card>
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
<Handshake className="w-8 h-8 text-gray-500" />
</div>
<h3 className="text-lg font-medium text-white mb-2">No Pending Requests</h3>
<p className="text-sm text-gray-400">
You don't have any pending sponsorship requests at the moment.
</p>
<p className="text-xs text-gray-500 mt-2">
Sponsors can apply to sponsor your profile, teams, or leagues you manage.
</p>
</div>
</Card>
) : (
<div className="space-y-6">
{sections.map((section) => {
const Icon = getEntityIcon(section.entityType);
const entityLink = getEntityLink(section.entityType, section.entityId);
return (
<Card key={`${section.entityType}-${section.entityId}`}>
{/* Section Header */}
<div className="flex items-center justify-between mb-6 pb-4 border-b border-charcoal-outline">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-iron-gray/50">
<Icon className="w-5 h-5 text-gray-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">{section.entityName}</h2>
<p className="text-xs text-gray-500 capitalize">{section.entityType}</p>
</div>
</div>
<Link
href={entityLink}
className="flex items-center gap-1 text-sm text-primary-blue hover:text-primary-blue/80 transition-colors"
>
View {section.entityType === 'season' ? 'Sponsorships' : section.entityType}
<ChevronRight className="w-4 h-4" />
</Link>
</div>
{/* Requests */}
<PendingSponsorshipRequests
entityType={section.entityType}
entityId={section.entityId}
entityName={section.entityName}
requests={section.requests}
onAccept={handleAccept}
onReject={handleReject}
/>
</Card>
);
})}
</div>
)}
{/* Info Card */}
<Card className="mt-8 bg-gradient-to-r from-primary-blue/5 to-transparent border-primary-blue/20">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/20 flex-shrink-0">
<Building className="w-5 h-5 text-primary-blue" />
</div>
<div>
<h3 className="text-sm font-semibold text-white mb-1">How Sponsorships Work</h3>
<p className="text-xs text-gray-400 leading-relaxed">
Sponsors can apply to sponsor your driver profile, teams you manage, or leagues you administer.
Review each request carefully - accepting will activate the sponsorship and the sponsor will be
charged. You'll receive the payment minus a 10% platform fee.
</p>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -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 (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto space-y-6">
@@ -424,6 +441,20 @@ export default function RaceDetailPage() {
</Button>
</div>
{/* Sponsor Insights Card - Consistent placement at top */}
{isSponsorMode && race && league && (
<SponsorInsightsCard
entityType="race"
entityId={raceId}
entityName={race.track}
tier="premium"
metrics={raceMetrics}
slots={SlotTemplates.race(true, 500)}
trustScore={sponsorInsights.trustScore}
monthlyActivity={sponsorInsights.monthlyActivity}
/>
)}
{/* User Result - Premium Achievement Card */}
{userResult && (
<div className={`
@@ -905,11 +936,11 @@ export default function RaceDetailPage() {
<XCircle className="w-4 h-4" />
{cancelling ? 'Cancelling...' : 'Cancel Race'}
</Button>
)}
</div>
</Card>
{/* Status Info */}
)}
</div>
</Card>
{/* Status Info */}
<Card className={`${config.bg} border ${config.border}`}>
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg ${config.bg}`}>

View File

@@ -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 (
<div className={`p-4 rounded-lg border ${method.isDefault ? 'border-primary-blue/50 bg-primary-blue/5' : 'border-charcoal-outline bg-iron-gray/30'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-iron-gray flex items-center justify-center">
<CreditCard className="w-5 h-5 text-gray-400" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-white">{method.brand} {method.last4}</span>
{method.isDefault && (
<span className="px-2 py-0.5 rounded text-xs bg-primary-blue/20 text-primary-blue">Default</span>
)}
</div>
{method.expiryMonth && method.expiryYear && (
<span className="text-sm text-gray-500">Expires {method.expiryMonth}/{method.expiryYear}</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{!method.isDefault && (
<Button variant="secondary" onClick={onSetDefault} className="text-xs">
Set Default
</Button>
)}
<Button variant="secondary" className="text-xs text-gray-400">
Remove
</Button>
</div>
</div>
</div>
);
}
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 (
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline last:border-b-0 hover:bg-iron-gray/20 transition-colors">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-iron-gray flex items-center justify-center">
<FileText className="w-5 h-5 text-gray-400" />
</div>
<div>
<div className="font-medium text-white">{invoice.description}</div>
<div className="text-sm text-gray-500">
{invoice.date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="font-semibold text-white">${invoice.amount.toLocaleString()}</div>
<div className={`flex items-center gap-1 text-xs ${status.color}`}>
<StatusIcon className="w-3 h-3" />
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
</div>
</div>
<Button variant="secondary" className="text-xs">
<Download className="w-3 h-3 mr-1" />
PDF
</Button>
</div>
</div>
);
}
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 (
<div className="max-w-4xl mx-auto py-8 px-4">
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<CreditCard className="w-7 h-7 text-warning-amber" />
Billing & Payments
</h1>
<p className="text-gray-400 mt-1">Manage payment methods and view invoices</p>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<Card className="p-4">
<div className="flex items-center gap-3 mb-2">
<DollarSign className="w-5 h-5 text-performance-green" />
<span className="text-sm text-gray-400">Total Spent</span>
</div>
<div className="text-2xl font-bold text-white">${totalSpent.toLocaleString()}</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3 mb-2">
<AlertTriangle className="w-5 h-5 text-warning-amber" />
<span className="text-sm text-gray-400">Pending</span>
</div>
<div className="text-2xl font-bold text-warning-amber">${pendingAmount.toLocaleString()}</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3 mb-2">
<Calendar className="w-5 h-5 text-primary-blue" />
<span className="text-sm text-gray-400">Next Payment</span>
</div>
<div className="text-lg font-bold text-white">Dec 15, 2025</div>
</Card>
</div>
{/* Payment Methods */}
<Card className="mb-8">
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline">
<h2 className="text-lg font-semibold text-white">Payment Methods</h2>
<Button variant="secondary" className="text-sm">
<Plus className="w-4 h-4 mr-2" />
Add Payment Method
</Button>
</div>
<div className="p-4 space-y-3">
{paymentMethods.map((method) => (
<PaymentMethodCard
key={method.id}
method={method}
onSetDefault={() => handleSetDefault(method.id)}
/>
))}
</div>
</Card>
{/* Billing History */}
<Card>
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline">
<h2 className="text-lg font-semibold text-white">Billing History</h2>
<Button variant="secondary" className="text-sm">
<Download className="w-4 h-4 mr-2" />
Export All
</Button>
</div>
<div>
{MOCK_INVOICES.map((invoice) => (
<InvoiceRow key={invoice.id} invoice={invoice} />
))}
</div>
<div className="p-4 border-t border-charcoal-outline">
<Button variant="secondary" className="w-full">
View All Invoices
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</Card>
{/* Platform Fee Notice */}
<div className="mt-6 rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<h3 className="font-medium text-white mb-2">Platform Fee</h3>
<p className="text-sm text-gray-400">
A 10% platform fee is applied to all sponsorship payments. This fee helps maintain the platform,
provide analytics, and ensure quality sponsorship placements.
</p>
</div>
{/* Alpha Notice */}
<div className="mt-6 rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> Payment processing is demonstration-only.
Real billing will be available when the payment system is fully implemented.
</p>
</div>
</div>
);
}

View File

@@ -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 (
<Card className="hover:border-charcoal-outline/80 transition-colors">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`px-2 py-1 rounded text-xs font-medium border ${tier.bg} ${tier.color} ${tier.border}`}>
{tier.label}
</div>
<div className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium ${status.bg} ${status.color}`}>
<StatusIcon className="w-3 h-3" />
{status.label}
</div>
</div>
<Button
variant="secondary"
onClick={() => router.push(`/leagues/${sponsorship.leagueId}`)}
className="text-xs"
>
<ExternalLink className="w-3 h-3 mr-1" />
View League
</Button>
</div>
<h3 className="text-lg font-semibold text-white mb-2">{sponsorship.leagueName}</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div className="bg-iron-gray/50 rounded-lg p-3">
<div className="flex items-center gap-1 text-gray-400 text-xs mb-1">
<Eye className="w-3 h-3" />
Impressions
</div>
<div className="text-white font-semibold">{sponsorship.impressions.toLocaleString()}</div>
</div>
<div className="bg-iron-gray/50 rounded-lg p-3">
<div className="flex items-center gap-1 text-gray-400 text-xs mb-1">
<Users className="w-3 h-3" />
Drivers
</div>
<div className="text-white font-semibold">{sponsorship.drivers}</div>
</div>
<div className="bg-iron-gray/50 rounded-lg p-3">
<div className="flex items-center gap-1 text-gray-400 text-xs mb-1">
<Calendar className="w-3 h-3" />
Period
</div>
<div className="text-white font-semibold text-xs">
{sponsorship.startDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} - {sponsorship.endDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</div>
</div>
<div className="bg-iron-gray/50 rounded-lg p-3">
<div className="flex items-center gap-1 text-gray-400 text-xs mb-1">
<Trophy className="w-3 h-3" />
Investment
</div>
<div className="text-white font-semibold">${sponsorship.price}</div>
</div>
</div>
<div className="flex items-center justify-between pt-3 border-t border-charcoal-outline/50">
<span className="text-xs text-gray-500">
{Math.ceil((sponsorship.endDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24))} days remaining
</span>
<Button
variant="secondary"
className="text-xs"
onClick={() => router.push(`/sponsor/campaigns/${sponsorship.id}`)}
>
View Details
<ChevronRight className="w-3 h-3 ml-1" />
</Button>
</div>
</Card>
);
}
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 (
<div className="max-w-6xl mx-auto py-8 px-4">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<Megaphone className="w-7 h-7 text-primary-blue" />
My Sponsorships
</h1>
<p className="text-gray-400 mt-1">Manage your league sponsorships</p>
</div>
<Button
variant="primary"
onClick={() => router.push('/leagues')}
>
<Plus className="w-4 h-4 mr-2" />
Find Leagues to Sponsor
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<Card className="p-4">
<div className="text-2xl font-bold text-white">{stats.total}</div>
<div className="text-sm text-gray-400">Total Sponsorships</div>
</Card>
<Card className="p-4">
<div className="text-2xl font-bold text-performance-green">{stats.active}</div>
<div className="text-sm text-gray-400">Active</div>
</Card>
<Card className="p-4">
<div className="text-2xl font-bold text-warning-amber">{stats.pending}</div>
<div className="text-sm text-gray-400">Pending</div>
</Card>
<Card className="p-4">
<div className="text-2xl font-bold text-white">${stats.totalInvestment.toLocaleString()}</div>
<div className="text-sm text-gray-400">Total Investment</div>
</Card>
</div>
{/* Filters */}
<div className="flex items-center gap-2 mb-6">
{(['all', 'active', 'pending', 'expired'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
filter === f
? 'bg-primary-blue text-white'
: 'bg-iron-gray/50 text-gray-400 hover:bg-iron-gray'
}`}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
{/* Sponsorship List */}
{filteredSponsorships.length === 0 ? (
<Card className="text-center py-12">
<Megaphone className="w-12 h-12 text-gray-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No sponsorships found</h3>
<p className="text-gray-400 mb-6">Start sponsoring leagues to grow your brand visibility</p>
<Button variant="primary" onClick={() => router.push('/leagues')}>
Browse Leagues
</Button>
</Card>
) : (
<div className="space-y-4">
{filteredSponsorships.map((sponsorship) => (
<SponsorshipCard key={sponsorship.id} sponsorship={sponsorship} />
))}
</div>
)}
{/* Alpha Notice */}
<div className="mt-8 rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> Sponsorship data shown here is demonstration-only.
Real sponsorship management will be available when the system is fully implemented.
</p>
</div>
</div>
);
}

View File

@@ -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 (
<Card className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10">
<Icon className="w-5 h-5 text-primary-blue" />
</div>
{change !== undefined && (
<div className={`flex items-center gap-1 text-sm ${
isPositive ? 'text-performance-green' : isNegative ? 'text-racing-red' : 'text-gray-400'
}`}>
{isPositive ? <ArrowUpRight className="w-4 h-4" /> : isNegative ? <ArrowDownRight className="w-4 h-4" /> : null}
{Math.abs(change)}%
</div>
)}
</div>
<div className="text-2xl font-bold text-white mb-1">
{typeof value === 'number' ? value.toLocaleString() : value}{suffix}
</div>
<div className="text-sm text-gray-400">{title}</div>
</Card>
);
}
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 (
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline last:border-b-0 hover:bg-iron-gray/30 transition-colors">
<div className="flex items-center gap-4">
<div className={`px-2 py-1 rounded text-xs font-medium border ${tierColors[league.tier]}`}>
{league.tier === 'main' ? 'Main Sponsor' : 'Secondary'}
</div>
<div>
<div className="font-medium text-white">{league.name}</div>
<div className="text-sm text-gray-400">
{league.drivers} drivers {league.races} races
</div>
</div>
</div>
<div className="flex items-center gap-6">
<div className="text-right">
<div className="text-sm font-medium text-white">{league.impressions.toLocaleString()}</div>
<div className="text-xs text-gray-500">impressions</div>
</div>
<div className={`px-2 py-1 rounded text-xs font-medium ${statusColors[league.status]}`}>
{league.status}
</div>
<Link href={`/leagues/${league.id}`}>
<Button variant="secondary" className="text-xs">
<ExternalLink className="w-3 h-3 mr-1" />
View
</Button>
</Link>
</div>
</div>
);
}
export default function SponsorDashboardPage() {
const [timeRange, setTimeRange] = useState<'7d' | '30d' | '90d' | 'all'>('30d');
const [data, setData] = useState<SponsorDashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
<Loader2 className="w-8 h-8 animate-spin text-primary-blue" />
</div>
);
}
const dashboardData = data || MOCK_DASHBOARD;
return (
<div className="max-w-7xl mx-auto py-8 px-4">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">Sponsor Dashboard</h1>
<p className="text-gray-400">Track your sponsorship performance and exposure</p>
</div>
<div className="flex items-center gap-2">
{(['7d', '30d', '90d', 'all'] as const).map((range) => (
<button
key={range}
onClick={() => setTimeRange(range)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
timeRange === range
? 'bg-primary-blue text-white'
: 'bg-iron-gray/50 text-gray-400 hover:bg-iron-gray'
}`}
>
{range === 'all' ? 'All Time' : range}
</button>
))}
</div>
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<MetricCard
title="Total Impressions"
value={dashboardData.metrics.impressions}
change={dashboardData.metrics.impressionsChange}
icon={Eye}
/>
<MetricCard
title="Unique Viewers"
value={dashboardData.metrics.uniqueViewers}
change={dashboardData.metrics.viewersChange}
icon={Users}
/>
<MetricCard
title="Exposure Score"
value={dashboardData.metrics.exposure}
change={dashboardData.metrics.exposureChange}
icon={Target}
suffix="%"
/>
<MetricCard
title="Active Drivers"
value={dashboardData.metrics.drivers}
icon={Trophy}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Sponsored Leagues */}
<div className="lg:col-span-2">
<Card>
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline">
<h2 className="text-lg font-semibold text-white">Sponsored Leagues</h2>
<Link href="/leagues">
<Button variant="secondary" className="text-sm">
Browse Leagues
</Button>
</Link>
</div>
<div>
{dashboardData.sponsoredLeagues.length > 0 ? (
dashboardData.sponsoredLeagues.map((league) => (
<LeagueRow key={league.id} league={league} />
))
) : (
<div className="p-8 text-center text-gray-400">
<p>No active sponsorships yet.</p>
<Link href="/leagues" className="text-primary-blue hover:underline mt-2 inline-block">
Browse leagues to sponsor
</Link>
</div>
)}
</div>
</Card>
</div>
{/* Quick Stats & Actions */}
<div className="space-y-6">
{/* Investment Summary */}
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Investment Summary</h3>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-gray-400">Active Sponsorships</span>
<span className="font-medium text-white">{dashboardData.investment.activeSponsorships}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-400">Total Investment</span>
<span className="font-medium text-white">${dashboardData.investment.totalInvestment.toLocaleString()}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-400">Cost per 1K Views</span>
<span className="font-medium text-performance-green">${dashboardData.investment.costPerThousandViews.toFixed(2)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-400">Next Payment</span>
<span className="font-medium text-white">Dec 15, 2025</span>
</div>
</div>
</Card>
{/* Recent Activity */}
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Recent Activity</h3>
<div className="space-y-3">
<div className="flex items-start gap-3">
<div className="w-2 h-2 rounded-full bg-performance-green mt-2" />
<div>
<p className="text-sm text-white">GT3 Pro Championship race completed</p>
<p className="text-xs text-gray-500">2 hours ago 1,240 views</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-2 h-2 rounded-full bg-primary-blue mt-2" />
<div>
<p className="text-sm text-white">New driver joined Endurance Masters</p>
<p className="text-xs text-gray-500">5 hours ago</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-2 h-2 rounded-full bg-warning-amber mt-2" />
<div>
<p className="text-sm text-white">Touring Car Cup season starting soon</p>
<p className="text-xs text-gray-500">1 day ago</p>
</div>
</div>
</div>
</Card>
{/* Quick Actions */}
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Quick Actions</h3>
<div className="space-y-2">
<Button variant="secondary" className="w-full justify-start">
<BarChart3 className="w-4 h-4 mr-2" />
View Detailed Analytics
</Button>
<Link href="/sponsor/billing" className="block">
<Button variant="secondary" className="w-full justify-start">
<DollarSign className="w-4 h-4 mr-2" />
Manage Payments
</Button>
</Link>
<Link href="/leagues" className="block">
<Button variant="secondary" className="w-full justify-start">
<Target className="w-4 h-4 mr-2" />
Find New Leagues
</Button>
</Link>
</div>
</Card>
</div>
</div>
{/* Alpha Notice */}
<div className="mt-8 rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> Sponsor analytics data shown here is demonstration-only.
Real analytics will be available when the sponsorship system is fully implemented.
</p>
</div>
</div>
);
}

View File

@@ -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 (
<div className="max-w-7xl mx-auto py-8 px-4">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-gray-400 mb-6">
<Link href="/sponsor/dashboard" className="hover:text-white">Dashboard</Link>
<ChevronRight className="w-4 h-4" />
<Link href="/sponsor/leagues" className="hover:text-white">Leagues</Link>
<ChevronRight className="w-4 h-4" />
<span className="text-white">{MOCK_LEAGUE.name}</span>
</div>
{/* Header */}
<div className="flex items-start justify-between mb-8">
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-2xl font-bold text-white">{MOCK_LEAGUE.name}</h1>
<span className="px-2 py-1 rounded text-xs font-medium bg-primary-blue/20 text-primary-blue border border-primary-blue/30">
Main Sponsor
</span>
</div>
<p className="text-gray-400">{MOCK_LEAGUE.season} {MOCK_LEAGUE.completedRaces}/{MOCK_LEAGUE.races} races completed</p>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary">
<ExternalLink className="w-4 h-4 mr-2" />
View League Page
</Button>
<Button variant="primary">
<Download className="w-4 h-4 mr-2" />
Download Report
</Button>
</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10">
<Eye className="w-5 h-5 text-primary-blue" />
</div>
<div>
<div className="text-xl font-bold text-white">{MOCK_LEAGUE.impressions.toLocaleString()}</div>
<div className="text-sm text-gray-400">Total Impressions</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10">
<TrendingUp className="w-5 h-5 text-performance-green" />
</div>
<div>
<div className="text-xl font-bold text-white">{MOCK_LEAGUE.avgViewsPerRace.toLocaleString()}</div>
<div className="text-sm text-gray-400">Avg Views/Race</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10">
<Users className="w-5 h-5 text-purple-400" />
</div>
<div>
<div className="text-xl font-bold text-white">{MOCK_LEAGUE.drivers}</div>
<div className="text-sm text-gray-400">Active Drivers</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/10">
<Calendar className="w-5 h-5 text-warning-amber" />
</div>
<div>
<div className="text-xl font-bold text-white">{MOCK_LEAGUE.races - MOCK_LEAGUE.completedRaces}</div>
<div className="text-sm text-gray-400">Races Remaining</div>
</div>
</div>
</Card>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-charcoal-outline">
{(['overview', 'drivers', 'races', 'assets'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium capitalize transition-colors border-b-2 -mb-px ${
activeTab === tab
? 'text-primary-blue border-primary-blue'
: 'text-gray-400 border-transparent hover:text-white'
}`}
>
{tab}
</button>
))}
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Sponsorship Details</h3>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-400">Tier</span>
<span className="text-white">Main Sponsor</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Logo Placement</span>
<span className="text-white">{MOCK_LEAGUE.logoPlacement}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Season Duration</span>
<span className="text-white">Oct 2025 - Feb 2026</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Investment</span>
<span className="text-white">$800/season</span>
</div>
</div>
</Card>
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Performance Metrics</h3>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-400">Cost per 1K Impressions</span>
<span className="text-performance-green">$17.70</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Engagement Rate</span>
<span className="text-white">4.2%</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Brand Recall Score</span>
<span className="text-white">78/100</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">ROI Estimate</span>
<span className="text-performance-green">+24%</span>
</div>
</div>
</Card>
</div>
)}
{activeTab === 'drivers' && (
<Card>
<div className="p-4 border-b border-charcoal-outline">
<h3 className="text-lg font-semibold text-white">Drivers Carrying Your Brand</h3>
<p className="text-sm text-gray-400">Top performing drivers with your sponsorship</p>
</div>
<div className="divide-y divide-charcoal-outline">
{MOCK_DRIVERS.map((driver) => (
<div key={driver.id} className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors">
<div className="flex items-center gap-4">
<div className="w-8 h-8 rounded-full bg-iron-gray flex items-center justify-center text-sm font-bold text-white">
{driver.position}
</div>
<div>
<div className="font-medium text-white">{driver.name}</div>
<div className="text-sm text-gray-400">{driver.country} {driver.races} races</div>
</div>
</div>
<div className="text-right">
<div className="font-medium text-white">{driver.impressions.toLocaleString()}</div>
<div className="text-xs text-gray-500">impressions</div>
</div>
</div>
))}
</div>
</Card>
)}
{activeTab === 'races' && (
<Card>
<div className="p-4 border-b border-charcoal-outline">
<h3 className="text-lg font-semibold text-white">Race Schedule & Performance</h3>
</div>
<div className="divide-y divide-charcoal-outline">
{MOCK_RACES.map((race) => (
<div key={race.id} className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors">
<div className="flex items-center gap-4">
<div className={`w-3 h-3 rounded-full ${
race.status === 'completed' ? 'bg-performance-green' : 'bg-warning-amber'
}`} />
<div>
<div className="font-medium text-white">{race.name}</div>
<div className="text-sm text-gray-400">{race.date}</div>
</div>
</div>
<div className="flex items-center gap-4">
{race.status === 'completed' ? (
<div className="text-right">
<div className="font-medium text-white">{race.views.toLocaleString()}</div>
<div className="text-xs text-gray-500">views</div>
</div>
) : (
<span className="text-sm text-warning-amber">Upcoming</span>
)}
</div>
</div>
))}
</div>
</Card>
)}
{activeTab === 'assets' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Your Logo Assets</h3>
<div className="space-y-4">
<div className="aspect-video bg-iron-gray rounded-lg flex items-center justify-center border border-charcoal-outline">
<ImageIcon className="w-12 h-12 text-gray-500" aria-label="Logo placeholder" />
</div>
<div className="flex gap-2">
<Button variant="secondary" className="flex-1">
<Download className="w-4 h-4 mr-2" />
Download Logo Pack
</Button>
<Button variant="secondary">
Update Logo
</Button>
</div>
</div>
</Card>
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Livery Preview</h3>
<div className="space-y-4">
<div className="aspect-video bg-iron-gray rounded-lg flex items-center justify-center border border-charcoal-outline">
<Trophy className="w-12 h-12 text-gray-500" />
</div>
<p className="text-sm text-gray-400">
Your logo appears on the primary hood position for all 32 drivers in this league.
</p>
<Button variant="secondary" className="w-full">
<Download className="w-4 h-4 mr-2" />
Download Sample Livery
</Button>
</div>
</Card>
</div>
)}
</div>
);
}

View File

@@ -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 (
<Card className={`overflow-hidden border ${tierColors[league.tier]}`}>
<div className="p-4">
<div className="flex items-start justify-between mb-3">
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-white">{league.name}</h3>
<span className={`px-2 py-0.5 rounded text-xs font-medium capitalize border ${tierBadgeColors[league.tier]}`}>
{league.tier}
</span>
</div>
<p className="text-sm text-gray-400">{league.game}</p>
</div>
<div className="flex items-center gap-1">
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
<span className="text-sm text-white">{league.rating}</span>
</div>
</div>
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="text-center p-2 bg-iron-gray/50 rounded">
<div className="text-lg font-bold text-white">{league.drivers}</div>
<div className="text-xs text-gray-500">Drivers</div>
</div>
<div className="text-center p-2 bg-iron-gray/50 rounded">
<div className="text-lg font-bold text-white">{(league.avgViewsPerRace / 1000).toFixed(1)}k</div>
<div className="text-xs text-gray-500">Avg Views</div>
</div>
<div className="text-center p-2 bg-iron-gray/50 rounded">
<div className="text-lg font-bold text-white">${(league.mainSponsorSlot.price / league.avgViewsPerRace * 1000).toFixed(0)}</div>
<div className="text-xs text-gray-500">CPM</div>
</div>
</div>
<div className="space-y-2 mb-4">
<div className="flex items-center justify-between p-2 bg-iron-gray/30 rounded">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${league.mainSponsorSlot.available ? 'bg-performance-green' : 'bg-racing-red'}`} />
<span className="text-sm text-gray-300">Main Sponsor</span>
</div>
<div className="text-sm">
{league.mainSponsorSlot.available ? (
<span className="text-white font-medium">${league.mainSponsorSlot.price}/season</span>
) : (
<span className="text-gray-500">Taken</span>
)}
</div>
</div>
<div className="flex items-center justify-between p-2 bg-iron-gray/30 rounded">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${league.secondarySlots.available > 0 ? 'bg-performance-green' : 'bg-racing-red'}`} />
<span className="text-sm text-gray-300">Secondary Slots</span>
</div>
<div className="text-sm">
{league.secondarySlots.available > 0 ? (
<span className="text-white font-medium">{league.secondarySlots.available}/{league.secondarySlots.total} @ ${league.secondarySlots.price}</span>
) : (
<span className="text-gray-500">Full</span>
)}
</div>
</div>
</div>
<div className="flex gap-2">
<Button variant="secondary" className="flex-1">
View Details
</Button>
{(league.mainSponsorSlot.available || league.secondarySlots.available > 0) && (
<Button variant="primary" className="flex-1">
Sponsor
</Button>
)}
</div>
</div>
</Card>
);
}
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 (
<div className="max-w-7xl mx-auto py-8 px-4">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-gray-400 mb-6">
<a href="/sponsor/dashboard" className="hover:text-white">Dashboard</a>
<ChevronRight className="w-4 h-4" />
<span className="text-white">Browse Leagues</span>
</div>
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-white mb-2">Find Leagues to Sponsor</h1>
<p className="text-gray-400">Discover racing leagues looking for sponsors and grow your brand</p>
</div>
{/* Filters */}
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
placeholder="Search leagues..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 rounded-lg border border-charcoal-outline bg-iron-gray text-white placeholder-gray-500 focus:border-primary-blue focus:outline-none"
/>
</div>
<div className="flex gap-2">
<select
value={tierFilter}
onChange={(e) => setTierFilter(e.target.value as typeof tierFilter)}
className="px-3 py-2 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
>
<option value="all">All Tiers</option>
<option value="premium">Premium</option>
<option value="standard">Standard</option>
<option value="starter">Starter</option>
</select>
<select
value={availabilityFilter}
onChange={(e) => setAvailabilityFilter(e.target.value as typeof availabilityFilter)}
className="px-3 py-2 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
>
<option value="all">All Slots</option>
<option value="main">Main Available</option>
<option value="secondary">Secondary Available</option>
</select>
</div>
</div>
{/* Stats Banner */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<Card className="p-4 text-center">
<div className="text-2xl font-bold text-white">{MOCK_AVAILABLE_LEAGUES.length}</div>
<div className="text-sm text-gray-400">Available Leagues</div>
</Card>
<Card className="p-4 text-center">
<div className="text-2xl font-bold text-performance-green">
{MOCK_AVAILABLE_LEAGUES.filter(l => l.mainSponsorSlot.available).length}
</div>
<div className="text-sm text-gray-400">Main Slots Open</div>
</Card>
<Card className="p-4 text-center">
<div className="text-2xl font-bold text-primary-blue">
{MOCK_AVAILABLE_LEAGUES.reduce((sum, l) => sum + l.secondarySlots.available, 0)}
</div>
<div className="text-sm text-gray-400">Secondary Slots Open</div>
</Card>
<Card className="p-4 text-center">
<div className="text-2xl font-bold text-white">
{MOCK_AVAILABLE_LEAGUES.reduce((sum, l) => sum + l.drivers, 0)}
</div>
<div className="text-sm text-gray-400">Total Drivers</div>
</Card>
</div>
{/* League Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredLeagues.map((league) => (
<LeagueCard key={league.id} league={league} />
))}
</div>
{filteredLeagues.length === 0 && (
<div className="text-center py-12">
<Trophy className="w-12 h-12 text-gray-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-white mb-2">No leagues found</h3>
<p className="text-gray-400">Try adjusting your filters to see more results</p>
</div>
)}
{/* Alpha Notice */}
<div className="mt-8 rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> League sponsorship marketplace is demonstration-only.
Actual sponsorship purchases will be available when the payment system is fully implemented.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function SponsorPage() {
redirect('/sponsor/dashboard');
}

View File

@@ -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 (
<label className="flex items-center justify-between cursor-pointer">
<span className="text-gray-300">{label}</span>
<button
type="button"
onClick={() => onChange(!checked)}
className={`relative w-11 h-6 rounded-full transition-colors ${checked ? 'bg-primary-blue' : 'bg-iron-gray'}`}
>
<span
className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform ${checked ? 'translate-x-5' : ''}`}
/>
</button>
</label>
);
}
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 (
<div className="max-w-3xl mx-auto py-8 px-4">
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<Settings className="w-7 h-7 text-gray-400" />
Sponsor Settings
</h1>
<p className="text-gray-400 mt-1">Manage your sponsor profile and preferences</p>
</div>
{/* Company Profile */}
<Card className="mb-6">
<div className="p-4 border-b border-charcoal-outline">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Building2 className="w-5 h-5 text-primary-blue" />
Company Profile
</h2>
</div>
<div className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Company Name</label>
<Input
type="text"
value={profile.name}
onChange={(e) => setProfile({ ...profile, name: e.target.value })}
placeholder="Your company name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-gray-500" />
Contact Email
</div>
</label>
<Input
type="email"
value={profile.email}
onChange={(e) => setProfile({ ...profile, email: e.target.value })}
placeholder="sponsor@company.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-gray-500" />
Website
</div>
</label>
<Input
type="url"
value={profile.website}
onChange={(e) => setProfile({ ...profile, website: e.target.value })}
placeholder="https://company.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Company Description</label>
<textarea
value={profile.description}
onChange={(e) => setProfile({ ...profile, description: e.target.value })}
placeholder="Tell leagues about your company..."
rows={3}
className="w-full px-3 py-2 bg-iron-gray border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<div className="flex items-center gap-2">
<Upload className="w-4 h-4 text-gray-500" />
Company Logo
</div>
</label>
<div className="flex items-center gap-4">
<div className="w-20 h-20 rounded-lg bg-iron-gray border border-charcoal-outline flex items-center justify-center">
<Building2 className="w-8 h-8 text-gray-500" />
</div>
<div>
<input
type="file"
accept="image/png,image/jpeg,image/svg+xml"
className="block w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary-blue/10 file:text-primary-blue hover:file:bg-primary-blue/20"
/>
<p className="text-xs text-gray-500 mt-1">PNG, JPEG, or SVG. Max 2MB.</p>
</div>
</div>
</div>
<div className="pt-4 border-t border-charcoal-outline flex items-center justify-between">
<Button
variant="primary"
onClick={handleSaveProfile}
disabled={saving}
>
{saving ? (
'Saving...'
) : saved ? (
<>
<Save className="w-4 h-4 mr-2" />
Saved!
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Save Changes
</>
)}
</Button>
</div>
</div>
</Card>
{/* Notification Preferences */}
<Card className="mb-6">
<div className="p-4 border-b border-charcoal-outline">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Bell className="w-5 h-5 text-warning-amber" />
Notifications
</h2>
</div>
<div className="p-4 space-y-4">
<Toggle
checked={notifications.emailNewSponsorships}
onChange={(checked) => setNotifications({ ...notifications, emailNewSponsorships: checked })}
label="Email when a sponsorship is approved"
/>
<Toggle
checked={notifications.emailWeeklyReport}
onChange={(checked) => setNotifications({ ...notifications, emailWeeklyReport: checked })}
label="Weekly analytics report"
/>
<Toggle
checked={notifications.emailRaceAlerts}
onChange={(checked) => setNotifications({ ...notifications, emailRaceAlerts: checked })}
label="Race day alerts for sponsored leagues"
/>
<Toggle
checked={notifications.emailPaymentAlerts}
onChange={(checked) => setNotifications({ ...notifications, emailPaymentAlerts: checked })}
label="Payment and invoice notifications"
/>
</div>
</Card>
{/* Privacy & Visibility */}
<Card className="mb-6">
<div className="p-4 border-b border-charcoal-outline">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Eye className="w-5 h-5 text-performance-green" />
Privacy & Visibility
</h2>
</div>
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-300">Public Profile</p>
<p className="text-sm text-gray-500">Allow leagues to see your sponsor profile</p>
</div>
<Toggle checked={true} onChange={() => {}} label="" />
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-gray-300">Show Sponsorship Stats</p>
<p className="text-sm text-gray-500">Display your total sponsorships and investment on profile</p>
</div>
<Toggle checked={false} onChange={() => {}} label="" />
</div>
</div>
</Card>
{/* Security */}
<Card className="mb-6">
<div className="p-4 border-b border-charcoal-outline">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Shield className="w-5 h-5 text-primary-blue" />
Security
</h2>
</div>
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-300">Change Password</p>
<p className="text-sm text-gray-500">Update your account password</p>
</div>
<Button variant="secondary" className="text-sm">
Change
</Button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-gray-300">Two-Factor Authentication</p>
<p className="text-sm text-gray-500">Add an extra layer of security</p>
</div>
<Button variant="secondary" className="text-sm">
Enable
</Button>
</div>
</div>
</Card>
{/* Danger Zone */}
<Card className="border-racing-red/30">
<div className="p-4 border-b border-racing-red/30">
<h2 className="text-lg font-semibold text-racing-red flex items-center gap-2">
<Trash2 className="w-5 h-5" />
Danger Zone
</h2>
</div>
<div className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-300">Delete Sponsor Account</p>
<p className="text-sm text-gray-500">Permanently delete your account and all sponsorship data</p>
</div>
<Button
variant="secondary"
onClick={handleDeleteAccount}
className="text-sm text-racing-red border-racing-red/30 hover:bg-racing-red/10"
>
Delete Account
</Button>
</div>
</div>
</Card>
{/* Alpha Notice */}
<div className="mt-6 rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> Settings are demonstration-only and won't persist.
Full account management will be available when the system is fully implemented.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,429 @@
'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 { Building2, Mail, Globe, Upload, Zap, Eye, TrendingUp, Users, ArrowRight } from 'lucide-react';
export default function SponsorSignupPage() {
const router = useRouter();
const [mode, setMode] = useState<'landing' | 'signup' | 'login'>('landing');
const [formData, setFormData] = useState({
name: '',
contactEmail: '',
websiteUrl: '',
logoFile: null as File | null,
password: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const handleDemoLogin = async () => {
setSubmitting(true);
try {
// Demo: Set cookie to indicate sponsor mode
document.cookie = 'gridpilot_demo_mode=sponsor; path=/; max-age=86400';
document.cookie = 'gridpilot_sponsor_id=demo-sponsor-1; path=/; max-age=86400';
await new Promise(resolve => setTimeout(resolve, 500));
router.push('/leagues');
} finally {
setSubmitting(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = 'Company name required';
}
if (!formData.contactEmail.trim()) {
newErrors.contactEmail = 'Contact email required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.contactEmail)) {
newErrors.contactEmail = 'Invalid email format';
}
if (mode === 'signup' && !formData.password.trim()) {
newErrors.password = 'Password required';
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setSubmitting(true);
try {
// Alpha: In-memory only, set demo sponsor cookie
console.log('Sponsor signup:', formData);
document.cookie = 'gridpilot_demo_mode=sponsor; path=/; max-age=86400';
document.cookie = `gridpilot_sponsor_name=${encodeURIComponent(formData.name)}; path=/; max-age=86400`;
await new Promise(resolve => setTimeout(resolve, 800));
router.push('/leagues');
} catch (err) {
console.error('Sponsor signup failed:', err);
alert('Registration failed. Try again.');
} finally {
setSubmitting(false);
}
};
// Landing page for sponsors
if (mode === 'landing') {
return (
<div className="max-w-4xl mx-auto py-12">
{/* Hero */}
<div className="text-center mb-12">
<div className="flex justify-center mb-6">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary-blue/10">
<Building2 className="w-8 h-8 text-primary-blue" />
</div>
</div>
<h1 className="text-4xl font-bold text-white mb-4">Sponsor Sim Racing Leagues</h1>
<p className="text-lg text-gray-400 max-w-2xl mx-auto">
Connect your brand with passionate sim racing communities. Get exposure through liveries, league branding, and engaged audiences.
</p>
</div>
{/* Benefits */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
<Card>
<div className="flex flex-col items-center text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10 mb-4">
<Eye className="w-6 h-6 text-primary-blue" />
</div>
<h3 className="text-lg font-semibold text-white mb-2">Brand Exposure</h3>
<p className="text-sm text-gray-400">
Your logo on liveries, league pages, and race broadcasts. Reach thousands of dedicated sim racers.
</p>
</div>
</Card>
<Card>
<div className="flex flex-col items-center text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-performance-green/10 mb-4">
<TrendingUp className="w-6 h-6 text-performance-green" />
</div>
<h3 className="text-lg font-semibold text-white mb-2">Analytics Dashboard</h3>
<p className="text-sm text-gray-400">
Track impressions, clicks, and engagement. See exactly how your sponsorship performs.
</p>
</div>
</Card>
<Card>
<div className="flex flex-col items-center text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-warning-amber/10 mb-4">
<Users className="w-6 h-6 text-warning-amber" />
</div>
<h3 className="text-lg font-semibold text-white mb-2">Engaged Audience</h3>
<p className="text-sm text-gray-400">
Connect with a passionate, tech-savvy community that values authentic partnerships.
</p>
</div>
</Card>
</div>
{/* CTA Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-8">
<Button
variant="primary"
onClick={() => setMode('signup')}
className="px-8 py-3"
>
<Building2 className="w-5 h-5 mr-2" />
Create Sponsor Account
</Button>
<Button
variant="secondary"
onClick={() => setMode('login')}
className="px-8 py-3"
>
Sign In
<ArrowRight className="w-5 h-5 ml-2" />
</Button>
</div>
{/* Demo Login */}
<div className="text-center">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-iron-gray/50 border border-charcoal-outline">
<Zap className="w-4 h-4 text-warning-amber" />
<span className="text-sm text-gray-400">Try it now:</span>
<button
onClick={handleDemoLogin}
disabled={submitting}
className="text-sm font-medium text-primary-blue hover:text-primary-blue/80 transition-colors disabled:opacity-50"
>
{submitting ? 'Loading...' : 'Demo Sponsor Login'}
</button>
</div>
</div>
{/* Alpha Notice */}
<div className="mt-12 rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400 text-center">
<strong className="text-warning-amber">Alpha Preview:</strong> Sponsorship features are demonstration-only.
In production, you'll have access to payment processing, detailed analytics, and automated livery placement.
</p>
</div>
</div>
);
}
// Login form
if (mode === 'login') {
return (
<div className="max-w-md mx-auto py-12">
<div className="mb-8">
<button
onClick={() => setMode('landing')}
className="text-sm text-gray-400 hover:text-white mb-4"
>
← Back
</button>
<div className="flex items-center gap-3 mb-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
<Building2 className="w-6 h-6 text-primary-blue" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Sponsor Sign In</h1>
<p className="text-sm text-gray-400">Access your sponsor dashboard</p>
</div>
</div>
</div>
<Card>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-gray-500" />
Email
</div>
</label>
<Input
type="email"
value={formData.contactEmail}
onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })}
placeholder="sponsor@company.com"
error={!!errors.contactEmail}
errorMessage={errors.contactEmail}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Password
</label>
<Input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="••••••••"
error={!!errors.password}
errorMessage={errors.password}
/>
</div>
<Button
type="submit"
variant="primary"
disabled={submitting}
className="w-full"
>
{submitting ? 'Signing in...' : 'Sign In'}
</Button>
</form>
<div className="mt-6 pt-6 border-t border-charcoal-outline text-center">
<p className="text-sm text-gray-400">
Don't have an account?{' '}
<button
onClick={() => setMode('signup')}
className="text-primary-blue hover:text-primary-blue/80"
>
Create one
</button>
</p>
</div>
<div className="mt-4 text-center">
<button
onClick={handleDemoLogin}
disabled={submitting}
className="text-sm text-gray-500 hover:text-gray-400"
>
Or use demo login
</button>
</div>
</Card>
</div>
);
}
// Signup form
return (
<div className="max-w-2xl mx-auto py-12">
{/* Header */}
<div className="mb-8">
<button
onClick={() => setMode('landing')}
className="text-sm text-gray-400 hover:text-white mb-4"
>
Back
</button>
<div className="flex items-center gap-3 mb-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
<Building2 className="w-6 h-6 text-primary-blue" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Create Sponsor Account</h1>
<p className="text-sm text-gray-400">Register your company to sponsor leagues</p>
</div>
</div>
</div>
<Card>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Company Name */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Company Name
</label>
<Input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Your company name"
error={!!errors.name}
errorMessage={errors.name}
/>
</div>
{/* Contact Email */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-gray-500" />
Contact Email
</div>
</label>
<Input
type="email"
value={formData.contactEmail}
onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })}
placeholder="sponsor@company.com"
error={!!errors.contactEmail}
errorMessage={errors.contactEmail}
/>
</div>
{/* Website URL */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-gray-500" />
Website URL (optional)
</div>
</label>
<Input
type="url"
value={formData.websiteUrl}
onChange={(e) => setFormData({ ...formData, websiteUrl: e.target.value })}
placeholder="https://company.com"
/>
</div>
{/* Logo Upload */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<div className="flex items-center gap-2">
<Upload className="w-4 h-4 text-gray-500" />
Company Logo (optional)
</div>
</label>
<div className="flex items-center gap-3">
<input
type="file"
accept="image/png,image/jpeg,image/svg+xml"
onChange={(e) => {
const file = e.target.files?.[0] || null;
setFormData({ ...formData, logoFile: file });
}}
className="block w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary-blue/10 file:text-primary-blue hover:file:bg-primary-blue/20"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
PNG, JPEG, or SVG. Recommended: 500x500px transparent background.
</p>
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Password
</label>
<Input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="Create a password"
error={!!errors.password}
errorMessage={errors.password}
/>
</div>
{/* Alpha Notice */}
<div className="rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> Sponsor registration is demonstration-only.
In production, you'll have access to full payment processing and analytics.
</p>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4">
<Button
type="submit"
variant="primary"
disabled={submitting}
className="flex-1"
>
{submitting ? 'Creating Account...' : 'Create Account'}
</Button>
<Button
type="button"
variant="secondary"
onClick={() => setMode('landing')}
disabled={submitting}
>
Cancel
</Button>
</div>
</form>
<div className="mt-6 pt-6 border-t border-charcoal-outline text-center">
<p className="text-sm text-gray-400">
Already have an account?{' '}
<button
onClick={() => setMode('login')}
className="text-primary-blue hover:text-primary-blue/80"
>
Sign in
</button>
</p>
</div>
</Card>
</div>
);
}

View File

@@ -3,9 +3,11 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import Image from 'next/image';
import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard';
import { getImageService } from '@/lib/di-container';
import TeamRoster from '@/components/teams/TeamRoster';
import TeamStandings from '@/components/teams/TeamStandings';
@@ -18,6 +20,7 @@ import {
} from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import type { Team, TeamMembership, TeamRole } from '@gridpilot/racing';
import { Users, Trophy, TrendingUp, Star, Zap } from 'lucide-react';
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
@@ -31,6 +34,7 @@ export default function TeamDetailPage() {
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const currentDriverId = useEffectiveDriverId();
const isSponsorMode = useSponsorMode();
const loadTeamData = useCallback(async () => {
setLoading(true);
@@ -152,6 +156,14 @@ export default function TeamDetailPage() {
const visibleTabs = tabs.filter(tab => tab.visible);
// Build sponsor insights for team
const teamMetrics = [
MetricBuilders.members(memberships.length),
MetricBuilders.reach(memberships.length * 15),
MetricBuilders.races(team.leagues.length * 8),
MetricBuilders.engagement(82),
];
return (
<div className="max-w-6xl mx-auto">
{/* Breadcrumb */}
@@ -163,6 +175,20 @@ export default function TeamDetailPage() {
]}
/>
{/* Sponsor Insights Card - Consistent placement at top */}
{isSponsorMode && team && (
<SponsorInsightsCard
entityType="team"
entityId={team.id}
entityName={team.name}
tier="standard"
metrics={teamMetrics}
slots={SlotTemplates.team(true, true, 500, 250)}
trustScore={90}
monthlyActivity={85}
/>
)}
<Card className="mb-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-6">

View File

@@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import { getSendNotificationUseCase } from '@/lib/di-container';
import type { NotificationUrgency } from '@gridpilot/notifications/application';
@@ -16,6 +17,10 @@ import {
MessageSquare,
AlertCircle,
BellRing,
User,
Building2,
LogOut,
LogIn,
} from 'lucide-react';
type DemoNotificationType = 'protest_filed' | 'defense_requested' | 'vote_required';
@@ -81,16 +86,81 @@ const urgencyOptions: UrgencyOption[] = [
},
];
type LoginMode = 'none' | 'driver' | 'sponsor';
export default function DevToolbar() {
const router = useRouter();
const [isExpanded, setIsExpanded] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [selectedType, setSelectedType] = useState<DemoNotificationType>('protest_filed');
const [selectedUrgency, setSelectedUrgency] = useState<DemoUrgency>('toast');
const [sending, setSending] = useState(false);
const [lastSent, setLastSent] = useState<string | null>(null);
const [loginMode, setLoginMode] = useState<LoginMode>('none');
const [loggingIn, setLoggingIn] = useState(false);
const currentDriverId = useEffectiveDriverId();
// Sync login mode with actual cookie state on mount
useEffect(() => {
if (typeof document !== 'undefined') {
const cookies = document.cookie.split(';');
const demoModeCookie = cookies.find(c => c.trim().startsWith('gridpilot_demo_mode='));
if (demoModeCookie) {
const value = demoModeCookie.split('=')[1]?.trim();
if (value === 'sponsor') {
setLoginMode('sponsor');
} else if (value === 'driver') {
setLoginMode('driver');
} else {
setLoginMode('none');
}
} else {
// Default to driver mode if no cookie (for demo purposes)
setLoginMode('driver');
}
}
}, []);
const handleLoginAsDriver = async () => {
setLoggingIn(true);
try {
// Demo: Set cookie to indicate driver mode
document.cookie = 'gridpilot_demo_mode=driver; path=/; max-age=86400';
setLoginMode('driver');
// Refresh to update all components that depend on demo mode
window.location.reload();
} finally {
setLoggingIn(false);
}
};
const handleLoginAsSponsor = async () => {
setLoggingIn(true);
try {
// Demo: Set cookie to indicate sponsor mode
document.cookie = 'gridpilot_demo_mode=sponsor; path=/; max-age=86400';
setLoginMode('sponsor');
// Navigate to sponsor dashboard
window.location.href = '/sponsor/dashboard';
} finally {
setLoggingIn(false);
}
};
const handleLogout = async () => {
setLoggingIn(true);
try {
// Demo: Clear demo mode cookie
document.cookie = 'gridpilot_demo_mode=; path=/; max-age=0';
setLoginMode('none');
// Refresh to update all components
window.location.href = '/';
} finally {
setLoggingIn(false);
}
};
// Only show in development
if (process.env.NODE_ENV === 'production') {
return null;
@@ -338,13 +408,72 @@ export default function DevToolbar() {
<strong className="text-gray-400">Modal:</strong> Blocking popup (requires action)
</p>
</div>
{/* Login Section */}
<div className="pt-4 border-t border-charcoal-outline">
<div className="flex items-center gap-2 mb-3">
<LogIn className="w-4 h-4 text-gray-400" />
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
Demo Login
</span>
</div>
<div className="space-y-2">
<button
onClick={handleLoginAsDriver}
disabled={loggingIn || loginMode === 'driver'}
className={`
w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-all
${loginMode === 'driver'
? 'bg-primary-blue/20 border-primary-blue/50 text-primary-blue'
: 'bg-iron-gray/30 border-charcoal-outline text-gray-300 hover:bg-iron-gray/50'
}
disabled:opacity-50 disabled:cursor-not-allowed
`}
>
<User className="w-4 h-4" />
{loginMode === 'driver' ? 'Logged in as Driver' : 'Login as Driver'}
</button>
<button
onClick={handleLoginAsSponsor}
disabled={loggingIn || loginMode === 'sponsor'}
className={`
w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-all
${loginMode === 'sponsor'
? 'bg-performance-green/20 border-performance-green/50 text-performance-green'
: 'bg-iron-gray/30 border-charcoal-outline text-gray-300 hover:bg-iron-gray/50'
}
disabled:opacity-50 disabled:cursor-not-allowed
`}
>
<Building2 className="w-4 h-4" />
{loginMode === 'sponsor' ? 'Logged in as Sponsor' : 'Login as Sponsor'}
</button>
{loginMode !== 'none' && (
<button
onClick={handleLogout}
disabled={loggingIn}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-red-500/30 bg-red-500/10 text-red-400 text-sm font-medium hover:bg-red-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<LogOut className="w-4 h-4" />
Logout
</button>
)}
</div>
<p className="text-[10px] text-gray-600 mt-2">
Switch between driver and sponsor views for demo purposes.
</p>
</div>
</div>
)}
{/* Collapsed state hint */}
{!isExpanded && (
<div className="px-4 py-2 text-xs text-gray-500">
Click to expand notification demo tools
Click to expand dev tools
</div>
)}
</div>

View File

@@ -22,6 +22,8 @@ import { LeagueStructureSection } from './LeagueStructureSection';
import { LeagueScoringSection } from './LeagueScoringSection';
import { LeagueDropSection } from './LeagueDropSection';
import { LeagueTimingsSection } from './LeagueTimingsSection';
import { LeagueSponsorshipsSection } from './LeagueSponsorshipsSection';
import { LeagueMembershipFeesSection } from './LeagueMembershipFeesSection';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import type { MembershipRole } from '@/lib/leagueMembership';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
@@ -29,7 +31,7 @@ import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappe
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import Modal from '@/components/ui/Modal';
import { AlertTriangle, CheckCircle, Clock, XCircle, Flag, Calendar, User } from 'lucide-react';
import { AlertTriangle, CheckCircle, Clock, XCircle, Flag, Calendar, User, DollarSign, Wallet, Paintbrush, Trophy, Download, Car, Upload } from 'lucide-react';
import type { Protest } from '@gridpilot/racing/domain/entities/Protest';
import type { Race } from '@gridpilot/racing/domain/entities/Race';
@@ -56,7 +58,8 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
const [ownerDriver, setOwnerDriver] = useState<DriverDTO | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings' | 'protests'>('members');
const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings' | 'protests' | 'sponsorships' | 'fees' | 'wallet' | 'prizes' | 'liveries'>('members');
const [downloadingLiveryPack, setDownloadingLiveryPack] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [configForm, setConfigForm] = useState<LeagueConfigFormModel | null>(null);
const [configLoading, setConfigLoading] = useState(false);
@@ -369,26 +372,26 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
{/* Admin Tabs */}
<div className="mb-6 border-b border-charcoal-outline">
<div className="flex gap-4">
<div className="flex gap-4 overflow-x-auto">
<button
onClick={() => setActiveTab('members')}
className={`pb-3 px-1 font-medium transition-colors ${
className={`pb-3 px-1 font-medium transition-colors whitespace-nowrap ${
activeTab === 'members'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Manage Members
Members
</button>
<button
onClick={() => setActiveTab('requests')}
className={`pb-3 px-1 font-medium transition-colors flex items-center gap-2 ${
className={`pb-3 px-1 font-medium transition-colors flex items-center gap-2 whitespace-nowrap ${
activeTab === 'requests'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Join Requests
Requests
{joinRequests.length > 0 && (
<span className="px-2 py-0.5 text-xs bg-primary-blue text-white rounded-full">
{joinRequests.length}
@@ -397,17 +400,67 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
</button>
<button
onClick={() => setActiveTab('races')}
className={`pb-3 px-1 font-medium transition-colors ${
className={`pb-3 px-1 font-medium transition-colors whitespace-nowrap ${
activeTab === 'races'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Create Race
Races
</button>
<button
onClick={() => setActiveTab('sponsorships')}
className={`pb-3 px-1 font-medium transition-colors whitespace-nowrap ${
activeTab === 'sponsorships'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Sponsors
</button>
<button
onClick={() => setActiveTab('fees')}
className={`pb-3 px-1 font-medium transition-colors whitespace-nowrap ${
activeTab === 'fees'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Fees
</button>
<button
onClick={() => setActiveTab('wallet')}
className={`pb-3 px-1 font-medium transition-colors whitespace-nowrap ${
activeTab === 'wallet'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Wallet
</button>
<button
onClick={() => setActiveTab('prizes')}
className={`pb-3 px-1 font-medium transition-colors whitespace-nowrap ${
activeTab === 'prizes'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Prizes
</button>
<button
onClick={() => setActiveTab('liveries')}
className={`pb-3 px-1 font-medium transition-colors whitespace-nowrap ${
activeTab === 'liveries'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Liveries
</button>
<button
onClick={() => setActiveTab('protests')}
className={`pb-3 px-1 font-medium transition-colors ${
className={`pb-3 px-1 font-medium transition-colors whitespace-nowrap ${
activeTab === 'protests'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
@@ -417,7 +470,7 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
</button>
<button
onClick={() => setActiveTab('settings')}
className={`pb-3 px-1 font-medium transition-colors ${
className={`pb-3 px-1 font-medium transition-colors whitespace-nowrap ${
activeTab === 'settings'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
@@ -681,6 +734,266 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
</Card>
)}
{activeTab === 'sponsorships' && (
<Card>
<LeagueSponsorshipsSection leagueId={league.id} />
</Card>
)}
{activeTab === 'fees' && (
<Card>
<LeagueMembershipFeesSection leagueId={league.id} />
</Card>
)}
{activeTab === 'wallet' && (
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">League Wallet</h2>
<p className="text-sm text-gray-400 mt-1">
Track revenue from sponsorships and membership fees
</p>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-warning-amber/10 border border-warning-amber/30">
<AlertTriangle className="w-4 h-4 text-warning-amber" />
<span className="text-xs font-medium text-warning-amber">Alpha Preview</span>
</div>
</div>
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-performance-green mb-1">
<Wallet className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Balance</span>
</div>
<div className="text-2xl font-bold text-white">$0.00</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-primary-blue mb-1">
<DollarSign className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Total Revenue</span>
</div>
<div className="text-2xl font-bold text-white">$0.00</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-warning-amber mb-1">
<DollarSign className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Platform Fees</span>
</div>
<div className="text-2xl font-bold text-white">$0.00</div>
</div>
</div>
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
<Wallet className="w-8 h-8 text-gray-500" />
</div>
<h3 className="text-lg font-medium text-white mb-2">No Transactions</h3>
<p className="text-sm text-gray-400 max-w-md mx-auto">
Revenue from sponsorships and membership fees will appear here.
</p>
</div>
<div className="mt-6 p-4 rounded-lg bg-iron-gray/30 border border-charcoal-outline/50">
<p className="text-xs text-gray-500">
<strong className="text-gray-400">Withdrawal Note:</strong> Funds can only be withdrawn after the season is completed.
A 10% platform fee applies to all revenue.
</p>
</div>
</Card>
)}
{activeTab === 'prizes' && (
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">Season Prizes</h2>
<p className="text-sm text-gray-400 mt-1">
Define prizes for championship positions
</p>
</div>
<Button variant="primary">
Add Prize
</Button>
</div>
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
<Trophy className="w-8 h-8 text-gray-500" />
</div>
<h3 className="text-lg font-medium text-white mb-2">No Prizes Defined</h3>
<p className="text-sm text-gray-400 max-w-md mx-auto">
Add prizes to be awarded to drivers at the end of the season based on final standings.
</p>
</div>
<div className="mt-6 p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> Prize management is demonstration-only.
In production, prizes are paid from the league wallet after season completion.
</p>
</div>
</Card>
)}
{activeTab === 'liveries' && (
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">Livery Management</h2>
<p className="text-sm text-gray-400 mt-1">
Upload templates and download composited livery packs
</p>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-warning-amber/10 border border-warning-amber/30">
<AlertTriangle className="w-4 h-4 text-warning-amber" />
<span className="text-xs font-medium text-warning-amber">Alpha Preview</span>
</div>
</div>
{/* Livery Templates Section */}
<div className="mb-8">
<h3 className="text-lg font-semibold text-white mb-4">Livery Templates</h3>
<p className="text-sm text-gray-400 mb-4">
Upload base liveries for each car allowed in the league. Position sponsor decals on these templates.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Example car templates */}
{[
{ id: 'car-1', name: 'Porsche 911 GT3 R', hasTemplate: false },
{ id: 'car-2', name: 'Ferrari 488 GT3', hasTemplate: false },
].map((car) => (
<div
key={car.id}
className="rounded-lg border border-charcoal-outline bg-deep-graphite/70 p-4"
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-iron-gray/50">
<Car className="w-6 h-6 text-gray-400" />
</div>
<div>
<h4 className="text-sm font-semibold text-white">{car.name}</h4>
<p className="text-xs text-gray-500 mt-0.5">
{car.hasTemplate ? 'Template uploaded' : 'No template'}
</p>
</div>
</div>
<Button variant="secondary" className="px-3 py-1.5">
<Upload className="w-4 h-4 mr-1" />
Upload
</Button>
</div>
</div>
))}
</div>
</div>
{/* Download Livery Pack Section */}
<div className="mb-8">
<h3 className="text-lg font-semibold text-white mb-4">Download Livery Pack</h3>
<p className="text-sm text-gray-400 mb-4">
Generate a .zip file containing all driver liveries with sponsor decals burned in.
Members and admins can use this pack in-game.
</p>
<div className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-6">
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-primary-blue mb-1">
<User className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Drivers</span>
</div>
<div className="text-2xl font-bold text-white">0</div>
<div className="text-xs text-gray-500">with uploaded liveries</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-performance-green mb-1">
<Paintbrush className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Templates</span>
</div>
<div className="text-2xl font-bold text-white">0</div>
<div className="text-xs text-gray-500">cars configured</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-warning-amber mb-1">
<DollarSign className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Sponsors</span>
</div>
<div className="text-2xl font-bold text-white">0</div>
<div className="text-xs text-gray-500">active this season</div>
</div>
</div>
<div className="flex items-center gap-4">
<Button
variant="primary"
onClick={async () => {
setDownloadingLiveryPack(true);
try {
// Alpha: Simulate pack generation
await new Promise(resolve => setTimeout(resolve, 1500));
alert('Livery pack generation is demonstration-only in alpha.');
} finally {
setDownloadingLiveryPack(false);
}
}}
disabled={downloadingLiveryPack}
className="px-6"
>
<Download className="w-4 h-4 mr-2" />
{downloadingLiveryPack ? 'Generating...' : 'Download Livery Pack'}
</Button>
<p className="text-xs text-gray-500">
Estimated size: ~50MB Includes all drivers
</p>
</div>
</div>
</div>
{/* Decal Placement Info */}
<div className="mb-6">
<h3 className="text-lg font-semibold text-white mb-4">How It Works</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4">
<h4 className="text-sm font-semibold text-white mb-2">1. Template Setup</h4>
<p className="text-xs text-gray-400">
Upload base liveries for each car. Position where sponsor logos will appear.
</p>
</div>
<div className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4">
<h4 className="text-sm font-semibold text-white mb-2">2. Driver Liveries</h4>
<p className="text-xs text-gray-400">
Drivers upload their personal liveries. Must be clean (no logos/text).
</p>
</div>
<div className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4">
<h4 className="text-sm font-semibold text-white mb-2">3. Sponsor Decals</h4>
<p className="text-xs text-gray-400">
Sponsor logos are automatically placed based on your template positions.
</p>
</div>
<div className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4">
<h4 className="text-sm font-semibold text-white mb-2">4. Pack Generation</h4>
<p className="text-xs text-gray-400">
Download .zip with all liveries composited. Ready for in-game use.
</p>
</div>
</div>
</div>
<div className="p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> Livery compositing and pack generation are demonstration-only.
In production, the system automatically validates liveries, places sponsor decals, and generates downloadable packs.
The companion app will also auto-install packs.
</p>
</div>
</Card>
)}
{activeTab === 'settings' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">League Settings</h2>

View File

@@ -0,0 +1,417 @@
'use client';
import { useState, useRef, useCallback } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
Move,
RotateCw,
ZoomIn,
ZoomOut,
Save,
Trash2,
Plus,
Image,
Target
} from 'lucide-react';
interface DecalPlacement {
id: string;
sponsorType: 'main' | 'secondary-1' | 'secondary-2';
sponsorName: string;
x: number; // 0-1 normalized
y: number; // 0-1 normalized
width: number; // 0-1 normalized
height: number; // 0-1 normalized
rotation: number; // 0-360 degrees
}
interface LeagueDecalPlacementEditorProps {
leagueId: string;
seasonId: string;
carId: string;
carName: string;
baseImageUrl?: string;
existingPlacements?: DecalPlacement[];
onSave?: (placements: DecalPlacement[]) => void;
}
const DEFAULT_PLACEMENTS: Omit<DecalPlacement, 'id'>[] = [
{
sponsorType: 'main',
sponsorName: 'Main Sponsor',
x: 0.3,
y: 0.15,
width: 0.4,
height: 0.15,
rotation: 0,
},
{
sponsorType: 'secondary-1',
sponsorName: 'Secondary Sponsor 1',
x: 0.05,
y: 0.5,
width: 0.15,
height: 0.1,
rotation: 0,
},
{
sponsorType: 'secondary-2',
sponsorName: 'Secondary Sponsor 2',
x: 0.8,
y: 0.5,
width: 0.15,
height: 0.1,
rotation: 0,
},
];
export default function LeagueDecalPlacementEditor({
leagueId,
seasonId,
carId,
carName,
baseImageUrl,
existingPlacements,
onSave,
}: LeagueDecalPlacementEditorProps) {
const canvasRef = useRef<HTMLDivElement>(null);
const [placements, setPlacements] = useState<DecalPlacement[]>(
existingPlacements ?? DEFAULT_PLACEMENTS.map((p, i) => ({ ...p, id: `decal-${i}` }))
);
const [selectedDecal, setSelectedDecal] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [zoom, setZoom] = useState(1);
const [saving, setSaving] = useState(false);
const selectedPlacement = placements.find(p => p.id === selectedDecal);
const handleDecalClick = (id: string) => {
setSelectedDecal(id === selectedDecal ? null : id);
};
const updatePlacement = useCallback((id: string, updates: Partial<DecalPlacement>) => {
setPlacements(prev => prev.map(p =>
p.id === id ? { ...p, ...updates } : p
));
}, []);
const handleMouseDown = (e: React.MouseEvent, decalId: string) => {
e.stopPropagation();
setSelectedDecal(decalId);
setIsDragging(true);
};
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isDragging || !selectedDecal || !canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
// Clamp to canvas bounds
const clampedX = Math.max(0, Math.min(1, x));
const clampedY = Math.max(0, Math.min(1, y));
updatePlacement(selectedDecal, { x: clampedX, y: clampedY });
}, [isDragging, selectedDecal, updatePlacement]);
const handleMouseUp = () => {
setIsDragging(false);
};
const handleRotate = (id: string, delta: number) => {
const placement = placements.find(p => p.id === id);
if (placement) {
const newRotation = (placement.rotation + delta + 360) % 360;
updatePlacement(id, { rotation: newRotation });
}
};
const handleResize = (id: string, scaleFactor: number) => {
const placement = placements.find(p => p.id === id);
if (placement) {
const newWidth = Math.max(0.05, Math.min(0.5, placement.width * scaleFactor));
const newHeight = Math.max(0.03, Math.min(0.3, placement.height * scaleFactor));
updatePlacement(id, { width: newWidth, height: newHeight });
}
};
const handleSave = async () => {
setSaving(true);
try {
// Alpha: In-memory save simulation
console.log('Saving decal placements:', {
leagueId,
seasonId,
carId,
placements,
});
await new Promise(resolve => setTimeout(resolve, 500));
onSave?.(placements);
alert('Decal placements saved successfully.');
} catch (err) {
console.error('Save failed:', err);
alert('Failed to save placements.');
} finally {
setSaving(false);
}
};
const getSponsorTypeColor = (type: DecalPlacement['sponsorType']) => {
switch (type) {
case 'main':
return { border: 'border-primary-blue', bg: 'bg-primary-blue/20', text: 'text-primary-blue' };
case 'secondary-1':
return { border: 'border-purple-500', bg: 'bg-purple-500/20', text: 'text-purple-400' };
case 'secondary-2':
return { border: 'border-purple-500', bg: 'bg-purple-500/20', text: 'text-purple-400' };
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-white">{carName}</h3>
<p className="text-sm text-gray-400">Position sponsor decals on this car's template</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
onClick={() => setZoom(z => Math.max(0.5, z - 0.25))}
disabled={zoom <= 0.5}
>
<ZoomOut className="w-4 h-4" />
</Button>
<span className="text-sm text-gray-400 min-w-[3rem] text-center">{Math.round(zoom * 100)}%</span>
<Button
variant="secondary"
onClick={() => setZoom(z => Math.min(2, z + 0.25))}
disabled={zoom >= 2}
>
<ZoomIn className="w-4 h-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Canvas */}
<div className="lg:col-span-2">
<div
ref={canvasRef}
className="relative aspect-video bg-deep-graphite rounded-lg border border-charcoal-outline overflow-hidden cursor-crosshair"
style={{ transform: `scale(${zoom})`, transformOrigin: 'top left' }}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{/* Base Image or Placeholder */}
{baseImageUrl ? (
<img
src={baseImageUrl}
alt="Livery template"
className="w-full h-full object-cover"
draggable={false}
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center">
<Image className="w-16 h-16 text-gray-600 mb-2" />
<p className="text-sm text-gray-500">No base template uploaded</p>
<p className="text-xs text-gray-600">Upload a template image first</p>
</div>
)}
{/* Decal Placeholders */}
{placements.map((placement) => {
const colors = getSponsorTypeColor(placement.sponsorType);
return (
<div
key={placement.id}
onMouseDown={(e) => handleMouseDown(e, placement.id)}
onClick={() => handleDecalClick(placement.id)}
className={`absolute cursor-move border-2 rounded flex items-center justify-center text-xs font-medium transition-all ${
selectedDecal === placement.id
? `${colors.border} ${colors.bg} ${colors.text} shadow-lg`
: `${colors.border} ${colors.bg} ${colors.text} opacity-70 hover:opacity-100`
}`}
style={{
left: `${placement.x * 100}%`,
top: `${placement.y * 100}%`,
width: `${placement.width * 100}%`,
height: `${placement.height * 100}%`,
transform: `translate(-50%, -50%) rotate(${placement.rotation}deg)`,
}}
>
<div className="text-center truncate px-1">
<div className="text-[10px] uppercase tracking-wide opacity-70">
{placement.sponsorType === 'main' ? 'Main' : 'Secondary'}
</div>
<div className="truncate">{placement.sponsorName}</div>
</div>
{/* Drag handle indicator */}
{selectedDecal === placement.id && (
<div className="absolute -top-1 -left-1 w-3 h-3 bg-white rounded-full border-2 border-primary-blue" />
)}
</div>
);
})}
{/* Grid overlay when dragging */}
{isDragging && (
<div className="absolute inset-0 pointer-events-none">
<div className="w-full h-full" style={{
backgroundImage: 'linear-gradient(to right, rgba(255,255,255,0.05) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.05) 1px, transparent 1px)',
backgroundSize: '10% 10%',
}} />
</div>
)}
</div>
<p className="text-xs text-gray-500 mt-2">
Click a decal to select it, then drag to reposition. Use controls on the right to adjust size and rotation.
</p>
</div>
{/* Controls Panel */}
<div className="space-y-4">
{/* Decal List */}
<Card className="p-4">
<h4 className="text-sm font-semibold text-white mb-3">Sponsor Slots</h4>
<div className="space-y-2">
{placements.map((placement) => {
const colors = getSponsorTypeColor(placement.sponsorType);
return (
<button
key={placement.id}
onClick={() => setSelectedDecal(placement.id)}
className={`w-full p-3 rounded-lg border text-left transition-all ${
selectedDecal === placement.id
? `${colors.border} ${colors.bg}`
: 'border-charcoal-outline bg-iron-gray/30 hover:bg-iron-gray/50'
}`}
>
<div className="flex items-center justify-between">
<div>
<div className={`text-xs font-medium uppercase ${colors.text}`}>
{placement.sponsorType === 'main' ? 'Main Sponsor' : `Secondary ${placement.sponsorType.split('-')[1]}`}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{Math.round(placement.x * 100)}%, {Math.round(placement.y * 100)}% • {placement.rotation}°
</div>
</div>
<Target className={`w-4 h-4 ${selectedDecal === placement.id ? colors.text : 'text-gray-500'}`} />
</div>
</button>
);
})}
</div>
</Card>
{/* Selected Decal Controls */}
{selectedPlacement && (
<Card className="p-4">
<h4 className="text-sm font-semibold text-white mb-3">Adjust Selected</h4>
{/* Position */}
<div className="mb-4">
<label className="block text-xs text-gray-400 mb-2">Position</label>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-gray-500 mb-1">X</label>
<input
type="range"
min="0"
max="100"
value={selectedPlacement.x * 100}
onChange={(e) => updatePlacement(selectedPlacement.id, { x: parseInt(e.target.value) / 100 })}
className="w-full h-2 bg-charcoal-outline rounded-lg appearance-none cursor-pointer accent-primary-blue"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Y</label>
<input
type="range"
min="0"
max="100"
value={selectedPlacement.y * 100}
onChange={(e) => updatePlacement(selectedPlacement.id, { y: parseInt(e.target.value) / 100 })}
className="w-full h-2 bg-charcoal-outline rounded-lg appearance-none cursor-pointer accent-primary-blue"
/>
</div>
</div>
</div>
{/* Size */}
<div className="mb-4">
<label className="block text-xs text-gray-400 mb-2">Size</label>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => handleResize(selectedPlacement.id, 0.9)}
className="flex-1"
>
<ZoomOut className="w-4 h-4 mr-1" />
Smaller
</Button>
<Button
variant="secondary"
onClick={() => handleResize(selectedPlacement.id, 1.1)}
className="flex-1"
>
<ZoomIn className="w-4 h-4 mr-1" />
Larger
</Button>
</div>
</div>
{/* Rotation */}
<div className="mb-4">
<label className="block text-xs text-gray-400 mb-2">Rotation: {selectedPlacement.rotation}°</label>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="360"
step="15"
value={selectedPlacement.rotation}
onChange={(e) => updatePlacement(selectedPlacement.id, { rotation: parseInt(e.target.value) })}
className="flex-1 h-2 bg-charcoal-outline rounded-lg appearance-none cursor-pointer accent-primary-blue"
/>
<Button
variant="secondary"
onClick={() => handleRotate(selectedPlacement.id, 90)}
className="px-2"
>
<RotateCw className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
)}
{/* Save Button */}
<Button
variant="primary"
onClick={handleSave}
disabled={saving}
className="w-full"
>
<Save className="w-4 h-4 mr-2" />
{saving ? 'Saving...' : 'Save Placements'}
</Button>
{/* Help Text */}
<div className="p-3 rounded-lg bg-iron-gray/30 border border-charcoal-outline/50">
<p className="text-xs text-gray-500">
<strong className="text-gray-400">Tip:</strong> Main sponsor gets the largest, most prominent placement.
Secondary sponsors get smaller positions. These decals will be burned onto all driver liveries.
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -15,12 +15,20 @@ import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
interface LeagueHeaderProps {
// Main sponsor info for "by XYZ" display
interface MainSponsorInfo {
name: string;
logoUrl?: string;
websiteUrl?: string;
}
export interface LeagueHeaderProps {
leagueId: string;
leagueName: string;
description?: string | null;
ownerId: string;
ownerName: string;
mainSponsor?: MainSponsorInfo | null;
}
export default function LeagueHeader({
@@ -29,6 +37,7 @@ export default function LeagueHeader({
description,
ownerId,
ownerName,
mainSponsor,
}: LeagueHeaderProps) {
const imageService = getImageService();
const logoUrl = imageService.getLeagueLogo(leagueId);
@@ -112,7 +121,26 @@ export default function LeagueHeader({
</div>
<div>
<div className="flex items-center gap-3 mb-1">
<h1 className="text-2xl font-bold text-white">{leagueName}</h1>
<h1 className="text-2xl font-bold text-white">
{leagueName}
{mainSponsor && (
<span className="text-gray-400 font-normal text-lg ml-2">
by{' '}
{mainSponsor.websiteUrl ? (
<a
href={mainSponsor.websiteUrl}
target="_blank"
rel="noreferrer"
className="text-primary-blue hover:text-primary-blue/80 transition-colors"
>
{mainSponsor.name}
</a>
) : (
<span className="text-primary-blue">{mainSponsor.name}</span>
)}
</span>
)}
</h1>
<MembershipStatus leagueId={leagueId} />
</div>
{description && (

View File

@@ -0,0 +1,239 @@
'use client';
import { useState } from 'react';
import Button from '../ui/Button';
import Input from '../ui/Input';
import { DollarSign, Calendar, User, TrendingUp } from 'lucide-react';
type FeeType = 'season' | 'monthly' | 'per_race';
interface MembershipFeeConfig {
type: FeeType;
amount: number;
enabled: boolean;
}
interface LeagueMembershipFeesSectionProps {
leagueId: string;
seasonId?: string;
readOnly?: boolean;
}
export function LeagueMembershipFeesSection({
leagueId,
seasonId,
readOnly = false
}: LeagueMembershipFeesSectionProps) {
const [feeConfig, setFeeConfig] = useState<MembershipFeeConfig>({
type: 'season',
amount: 0,
enabled: false,
});
const [tempAmount, setTempAmount] = useState<string>('0');
const [editing, setEditing] = useState(false);
const handleEnableFees = () => {
setFeeConfig({ ...feeConfig, enabled: true });
setEditing(true);
setTempAmount(feeConfig.amount.toString());
};
const handleSave = () => {
const amount = parseFloat(tempAmount);
if (!isNaN(amount) && amount > 0) {
setFeeConfig({ ...feeConfig, amount });
}
setEditing(false);
};
const handleCancel = () => {
setEditing(false);
setTempAmount(feeConfig.amount.toString());
};
const handleTypeChange = (type: FeeType) => {
setFeeConfig({ ...feeConfig, type });
};
const platformFee = feeConfig.amount * 0.10;
const netAmount = feeConfig.amount - platformFee;
const typeLabels: Record<FeeType, string> = {
season: 'Season Fee',
monthly: 'Monthly Subscription',
per_race: 'Per-Race Fee',
};
const typeDescriptions: Record<FeeType, string> = {
season: 'Single payment for entire season',
monthly: 'Recurring monthly payment',
per_race: 'Payment required per race entry',
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-white">Membership Fees</h3>
<p className="text-sm text-gray-400 mt-1">
Charge drivers for league participation
</p>
</div>
{!feeConfig.enabled && !readOnly && (
<Button
variant="primary"
onClick={handleEnableFees}
>
Enable Fees
</Button>
)}
</div>
{!feeConfig.enabled ? (
<div className="text-center py-12 rounded-lg border border-charcoal-outline bg-iron-gray/30">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
<DollarSign className="w-8 h-8 text-gray-500" />
</div>
<h4 className="text-lg font-medium text-white mb-2">No Membership Fees</h4>
<p className="text-sm text-gray-400 max-w-md mx-auto">
This league is free to join. Enable membership fees to charge drivers for participation.
</p>
</div>
) : (
<>
{/* Fee Type Selection */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-300">
Fee Type
</label>
<div className="grid grid-cols-3 gap-3">
{(['season', 'monthly', 'per_race'] as FeeType[]).map((type) => {
const Icon = type === 'season' ? Calendar : type === 'monthly' ? TrendingUp : User;
const isSelected = feeConfig.type === type;
return (
<button
key={type}
type="button"
onClick={() => handleTypeChange(type)}
disabled={readOnly}
className={`p-4 rounded-lg border transition-all ${
isSelected
? 'border-primary-blue bg-primary-blue/10'
: 'border-charcoal-outline bg-iron-gray/30 hover:border-primary-blue/50'
}`}
>
<Icon className={`w-5 h-5 mx-auto mb-2 ${
isSelected ? 'text-primary-blue' : 'text-gray-400'
}`} />
<div className="text-sm font-medium text-white mb-1">
{typeLabels[type]}
</div>
<div className="text-xs text-gray-500">
{typeDescriptions[type]}
</div>
</button>
);
})}
</div>
</div>
{/* Amount Configuration */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-300">
Amount
</label>
{editing ? (
<div className="flex items-center gap-3">
<div className="flex-1">
<Input
type="number"
value={tempAmount}
onChange={(e) => setTempAmount(e.target.value)}
placeholder="0.00"
min="0"
step="0.01"
/>
</div>
<Button
variant="primary"
onClick={handleSave}
className="px-4"
>
Save
</Button>
<Button
variant="secondary"
onClick={handleCancel}
className="px-4"
>
Cancel
</Button>
</div>
) : (
<div className="flex items-center justify-between p-4 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<div>
<div className="text-2xl font-bold text-white">
${feeConfig.amount.toFixed(2)}
</div>
<div className="text-xs text-gray-500 mt-1">
{typeLabels[feeConfig.type]}
</div>
</div>
{!readOnly && (
<Button
variant="secondary"
onClick={() => setEditing(true)}
className="px-4"
>
Edit Amount
</Button>
)}
</div>
)}
</div>
{/* Revenue Breakdown */}
{feeConfig.amount > 0 && (
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="text-xs text-gray-400 mb-1">Platform Fee (10%)</div>
<div className="text-lg font-bold text-warning-amber">
-${platformFee.toFixed(2)}
</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="text-xs text-gray-400 mb-1">Net per Driver</div>
<div className="text-lg font-bold text-performance-green">
${netAmount.toFixed(2)}
</div>
</div>
</div>
)}
{/* Disable Fees */}
{!readOnly && (
<div className="pt-4 border-t border-charcoal-outline">
<Button
variant="danger"
onClick={() => setFeeConfig({ type: 'season', amount: 0, enabled: false })}
>
Disable Membership Fees
</Button>
</div>
)}
</>
)}
{/* Alpha Notice */}
<div className="rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> Membership fee collection is demonstration-only.
In production, fees are collected via payment gateway and deposited to league wallet (minus platform fee).
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,315 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Button from '../ui/Button';
import Input from '../ui/Input';
import { DollarSign, Star, Award, Plus, X, Bell } from 'lucide-react';
import PendingSponsorshipRequests, { type PendingRequestDTO } from '../sponsors/PendingSponsorshipRequests';
import {
getGetPendingSponsorshipRequestsQuery,
getAcceptSponsorshipRequestUseCase,
getRejectSponsorshipRequestUseCase,
getSeasonRepository,
} from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
interface SponsorshipSlot {
tier: 'main' | 'secondary';
sponsorName?: string;
logoUrl?: string;
price: number;
isOccupied: boolean;
}
interface LeagueSponsorshipsSectionProps {
leagueId: string;
seasonId?: string;
readOnly?: boolean;
}
export function LeagueSponsorshipsSection({
leagueId,
seasonId: propSeasonId,
readOnly = false
}: LeagueSponsorshipsSectionProps) {
const currentDriverId = useEffectiveDriverId();
const [slots, setSlots] = useState<SponsorshipSlot[]>([
{ tier: 'main', price: 500, isOccupied: false },
{ tier: 'secondary', price: 200, isOccupied: false },
{ tier: 'secondary', price: 200, isOccupied: false },
]);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [tempPrice, setTempPrice] = useState<string>('');
const [pendingRequests, setPendingRequests] = useState<PendingRequestDTO[]>([]);
const [requestsLoading, setRequestsLoading] = useState(false);
const [seasonId, setSeasonId] = useState<string | undefined>(propSeasonId);
// Load season ID if not provided
useEffect(() => {
async function loadSeasonId() {
if (propSeasonId) {
setSeasonId(propSeasonId);
return;
}
try {
const seasonRepo = getSeasonRepository();
const seasons = await seasonRepo.findByLeagueId(leagueId);
const activeSeason = seasons.find(s => s.status === 'active') ?? seasons[0];
if (activeSeason) {
setSeasonId(activeSeason.id);
}
} catch (err) {
console.error('Failed to load season:', err);
}
}
loadSeasonId();
}, [leagueId, propSeasonId]);
// Load pending sponsorship requests
const loadPendingRequests = useCallback(async () => {
if (!seasonId) return;
setRequestsLoading(true);
try {
const query = getGetPendingSponsorshipRequestsQuery();
const result = await query.execute({
entityType: 'season',
entityId: seasonId,
});
setPendingRequests(result.requests);
} catch (err) {
console.error('Failed to load pending requests:', err);
} finally {
setRequestsLoading(false);
}
}, [seasonId]);
useEffect(() => {
loadPendingRequests();
}, [loadPendingRequests]);
const handleAcceptRequest = async (requestId: string) => {
try {
const useCase = getAcceptSponsorshipRequestUseCase();
await useCase.execute({
requestId,
respondedBy: currentDriverId,
});
await loadPendingRequests();
} catch (err) {
console.error('Failed to accept request:', err);
alert(err instanceof Error ? err.message : 'Failed to accept request');
}
};
const handleRejectRequest = async (requestId: string, reason?: string) => {
try {
const useCase = getRejectSponsorshipRequestUseCase();
await useCase.execute({
requestId,
respondedBy: currentDriverId,
reason,
});
await loadPendingRequests();
} catch (err) {
console.error('Failed to reject request:', err);
alert(err instanceof Error ? err.message : 'Failed to reject request');
}
};
const handleEditPrice = (index: number) => {
setEditingIndex(index);
setTempPrice(slots[index].price.toString());
};
const handleSavePrice = (index: number) => {
const price = parseFloat(tempPrice);
if (!isNaN(price) && price > 0) {
const updated = [...slots];
updated[index].price = price;
setSlots(updated);
}
setEditingIndex(null);
setTempPrice('');
};
const handleCancelEdit = () => {
setEditingIndex(null);
setTempPrice('');
};
const totalRevenue = slots.reduce((sum, slot) =>
slot.isOccupied ? sum + slot.price : sum, 0
);
const platformFee = totalRevenue * 0.10;
const netRevenue = totalRevenue - platformFee;
const availableSlots = slots.filter(s => !s.isOccupied).length;
const occupiedSlots = slots.filter(s => s.isOccupied).length;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-white">Sponsorships</h3>
<p className="text-sm text-gray-400 mt-1">
Define pricing for main and secondary sponsor slots
</p>
</div>
{!readOnly && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-blue/10 border border-primary-blue/30">
<DollarSign className="w-4 h-4 text-primary-blue" />
<span className="text-xs font-medium text-primary-blue">
{availableSlots} slot{availableSlots !== 1 ? 's' : ''} available
</span>
</div>
)}
</div>
{/* Revenue Summary */}
{totalRevenue > 0 && (
<div className="grid grid-cols-3 gap-4">
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="text-xs text-gray-400 mb-1">Total Revenue</div>
<div className="text-xl font-bold text-white">
${totalRevenue.toFixed(2)}
</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="text-xs text-gray-400 mb-1">Platform Fee (10%)</div>
<div className="text-xl font-bold text-warning-amber">
-${platformFee.toFixed(2)}
</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="text-xs text-gray-400 mb-1">Net Revenue</div>
<div className="text-xl font-bold text-performance-green">
${netRevenue.toFixed(2)}
</div>
</div>
</div>
)}
{/* Sponsorship Slots */}
<div className="space-y-3">
{slots.map((slot, index) => {
const isEditing = editingIndex === index;
const Icon = slot.tier === 'main' ? Star : Award;
return (
<div
key={index}
className="rounded-lg border border-charcoal-outline bg-deep-graphite/70 p-4"
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${
slot.tier === 'main'
? 'bg-primary-blue/10'
: 'bg-gray-500/10'
}`}>
<Icon className={`w-5 h-5 ${
slot.tier === 'main'
? 'text-primary-blue'
: 'text-gray-400'
}`} />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-white">
{slot.tier === 'main' ? 'Main Sponsor' : 'Secondary Sponsor'}
</h4>
{slot.isOccupied && (
<span className="px-2 py-0.5 text-xs bg-performance-green/10 text-performance-green border border-performance-green/30 rounded-full">
Occupied
</span>
)}
</div>
<p className="text-xs text-gray-500 mt-0.5">
{slot.tier === 'main'
? 'Big livery slot • League page logo • Name in league title'
: 'Small livery slot • League page logo'}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{isEditing ? (
<div className="flex items-center gap-2">
<Input
type="number"
value={tempPrice}
onChange={(e) => setTempPrice(e.target.value)}
placeholder="Price"
className="w-32"
min="0"
step="0.01"
/>
<Button
variant="primary"
onClick={() => handleSavePrice(index)}
className="px-3 py-1"
>
Save
</Button>
<Button
variant="secondary"
onClick={handleCancelEdit}
className="px-3 py-1"
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<>
<div className="text-right">
<div className="text-lg font-bold text-white">
${slot.price.toFixed(2)}
</div>
<div className="text-xs text-gray-500">per season</div>
</div>
{!readOnly && !slot.isOccupied && (
<Button
variant="secondary"
onClick={() => handleEditPrice(index)}
className="px-3 py-1"
>
Edit Price
</Button>
)}
</>
)}
</div>
</div>
</div>
);
})}
</div>
{/* Pending Sponsorship Requests */}
{!readOnly && (pendingRequests.length > 0 || requestsLoading) && (
<div className="mt-8 pt-6 border-t border-charcoal-outline">
<PendingSponsorshipRequests
entityType="season"
entityId={seasonId || ''}
entityName="this league"
requests={pendingRequests}
onAccept={handleAcceptRequest}
onReject={handleRejectRequest}
isLoading={requestsLoading}
/>
</div>
)}
{/* Alpha Notice */}
<div className="rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> Sponsorship management is demonstration-only.
In production, sponsors can browse leagues, select slots, and complete payment integration.
</p>
</div>
</div>
);
}

View File

@@ -2,7 +2,7 @@
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { LogOut, Settings, Star } from 'lucide-react';
import { LogOut, Settings, Star, Paintbrush, Building2, BarChart3, Megaphone, CreditCard, Handshake } from 'lucide-react';
import { useAuth } from '@/lib/auth/AuthContext';
import {
getDriverStats,
@@ -16,10 +16,26 @@ import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
// Hook to detect sponsor mode
function useSponsorMode(): boolean {
const [isSponsor, setIsSponsor] = useState(false);
useEffect(() => {
const cookie = document.cookie
.split('; ')
.find(row => row.startsWith('gridpilot_demo_mode='));
if (cookie) {
const value = cookie.split('=')[1];
setIsSponsor(value === 'sponsor');
}
}, []);
return isSponsor;
}
export default function UserPill() {
const { session, login } = useAuth();
const [driver, setDriver] = useState<DriverDTO | null>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const isSponsorMode = useSponsorMode();
const user = session?.user as
| {
@@ -103,6 +119,79 @@ export default function UserPill() {
};
}, [session, driver, primaryDriverId]);
// Sponsor mode UI - check BEFORE session check so sponsors without auth still see sponsor UI
if (isSponsorMode) {
return (
<div className="relative inline-flex items-center">
<button
onClick={() => setIsMenuOpen((open) => !open)}
className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-performance-green/10 border border-performance-green/30 hover:bg-performance-green/20 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-performance-green/20 flex items-center justify-center">
<Building2 className="w-4 h-4 text-performance-green" />
</div>
<span className="text-sm font-semibold text-performance-green">Sponsor</span>
</button>
{isMenuOpen && (
<div className="absolute right-0 top-full mt-2 w-56 rounded-lg bg-deep-graphite border border-charcoal-outline shadow-lg z-50">
<div className="p-3 border-b border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wide">Demo Sponsor Account</p>
<p className="text-sm font-semibold text-white mt-1">Acme Racing Co.</p>
</div>
<div className="py-1 text-sm text-gray-200">
<Link
href="/sponsor"
className="flex items-center gap-2 px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<BarChart3 className="h-4 w-4 text-performance-green" />
<span>Dashboard</span>
</Link>
<Link
href="/sponsor/campaigns"
className="flex items-center gap-2 px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Megaphone className="h-4 w-4 text-primary-blue" />
<span>My Sponsorships</span>
</Link>
<Link
href="/sponsor/billing"
className="flex items-center gap-2 px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<CreditCard className="h-4 w-4 text-warning-amber" />
<span>Billing</span>
</Link>
<Link
href="/sponsor/settings"
className="flex items-center gap-2 px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Settings className="h-4 w-4" />
<span>Settings</span>
</Link>
</div>
<div className="border-t border-charcoal-outline">
<button
type="button"
onClick={() => {
document.cookie = 'gridpilot_demo_mode=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
window.location.reload();
}}
className="flex w-full items-center justify-between px-3 py-2 text-sm text-gray-400 hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<span>Exit Sponsor Mode</span>
<LogOut className="h-4 w-4" />
</button>
</div>
</div>
)}
</div>
);
}
if (!session) {
return (
<div className="flex items-center gap-2">
@@ -153,6 +242,22 @@ export default function UserPill() {
>
Manage leagues
</Link>
<Link
href="/profile/liveries"
className="flex items-center gap-2 px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Paintbrush className="h-4 w-4" />
<span>Liveries</span>
</Link>
<Link
href="/profile/sponsorship-requests"
className="flex items-center gap-2 px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Handshake className="h-4 w-4 text-performance-green" />
<span>Sponsorship Requests</span>
</Link>
<Link
href="/profile/settings"
className="flex items-center gap-2 px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"

View File

@@ -0,0 +1,241 @@
'use client';
import React, { useState, useEffect } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Clock, Check, X, DollarSign, MessageCircle, User, Building } from 'lucide-react';
export interface PendingRequestDTO {
id: string;
sponsorId: string;
sponsorName: string;
sponsorLogo?: string;
tier: 'main' | 'secondary';
offeredAmount: number;
currency: string;
formattedAmount: string;
message?: string;
createdAt: Date;
platformFee: number;
netAmount: number;
}
interface PendingSponsorshipRequestsProps {
entityType: 'driver' | 'team' | 'race' | 'season';
entityId: string;
entityName: string;
requests: PendingRequestDTO[];
onAccept: (requestId: string) => Promise<void>;
onReject: (requestId: string, reason?: string) => Promise<void>;
isLoading?: boolean;
}
export default function PendingSponsorshipRequests({
entityType,
entityId,
entityName,
requests,
onAccept,
onReject,
isLoading = false,
}: PendingSponsorshipRequestsProps) {
const [processingId, setProcessingId] = useState<string | null>(null);
const [rejectModalId, setRejectModalId] = useState<string | null>(null);
const [rejectReason, setRejectReason] = useState('');
const handleAccept = async (requestId: string) => {
setProcessingId(requestId);
try {
await onAccept(requestId);
} finally {
setProcessingId(null);
}
};
const handleReject = async (requestId: string) => {
setProcessingId(requestId);
try {
await onReject(requestId, rejectReason || undefined);
setRejectModalId(null);
setRejectReason('');
} finally {
setProcessingId(null);
}
};
if (isLoading) {
return (
<div className="text-center py-8 text-gray-400">
<div className="animate-pulse">Loading sponsorship requests...</div>
</div>
);
}
if (requests.length === 0) {
return (
<div className="text-center py-8">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-iron-gray/50 flex items-center justify-center">
<Building className="w-6 h-6 text-gray-500" />
</div>
<p className="text-gray-400 text-sm">No pending sponsorship requests</p>
<p className="text-gray-500 text-xs mt-1">
When sponsors apply to sponsor this {entityType}, their requests will appear here.
</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">Sponsorship Requests</h3>
<span className="px-2 py-1 text-xs bg-primary-blue/20 text-primary-blue rounded-full">
{requests.length} pending
</span>
</div>
<div className="space-y-3">
{requests.map((request) => {
const isProcessing = processingId === request.id;
const isRejecting = rejectModalId === request.id;
return (
<div
key={request.id}
className="rounded-lg border border-charcoal-outline bg-deep-graphite/70 p-4"
>
{/* Reject Modal */}
{isRejecting && (
<div className="mb-4 p-4 rounded-lg bg-iron-gray/50 border border-red-500/30">
<h4 className="text-sm font-medium text-white mb-2">
Reject sponsorship from {request.sponsorName}?
</h4>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="Optional: Provide a reason for rejection..."
rows={2}
className="w-full rounded-lg border border-charcoal-outline bg-iron-gray/80 px-3 py-2 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500 mb-3"
/>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => {
setRejectModalId(null);
setRejectReason('');
}}
className="px-3 py-1 text-xs"
>
Cancel
</Button>
<Button
variant="primary"
onClick={() => handleReject(request.id)}
disabled={isProcessing}
className="px-3 py-1 text-xs bg-red-600 hover:bg-red-700"
>
{isProcessing ? 'Rejecting...' : 'Confirm Reject'}
</Button>
</div>
</div>
)}
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
{/* Sponsor Logo */}
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-iron-gray/50 shrink-0">
{request.sponsorLogo ? (
<img
src={request.sponsorLogo}
alt={request.sponsorName}
className="w-8 h-8 object-contain"
/>
) : (
<Building className="w-6 h-6 text-gray-400" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-semibold text-white truncate">
{request.sponsorName}
</h4>
<span
className={`px-2 py-0.5 text-xs rounded-full ${
request.tier === 'main'
? 'bg-primary-blue/20 text-primary-blue'
: 'bg-gray-500/20 text-gray-400'
}`}
>
{request.tier === 'main' ? 'Main Sponsor' : 'Secondary'}
</span>
</div>
{/* Offer Details */}
<div className="flex flex-wrap gap-3 text-xs mb-2">
<div className="flex items-center gap-1 text-performance-green">
<DollarSign className="w-3 h-3" />
<span className="font-semibold">{request.formattedAmount}</span>
</div>
<div className="flex items-center gap-1 text-gray-500">
<span>Net: ${(request.netAmount / 100).toFixed(2)}</span>
</div>
<div className="flex items-center gap-1 text-gray-500">
<Clock className="w-3 h-3" />
<span>
{new Date(request.createdAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})}
</span>
</div>
</div>
{/* Message */}
{request.message && (
<div className="flex items-start gap-1.5 text-xs text-gray-400 bg-iron-gray/30 rounded p-2">
<MessageCircle className="w-3 h-3 mt-0.5 shrink-0" />
<span className="line-clamp-2">{request.message}</span>
</div>
)}
</div>
</div>
{/* Actions */}
{!isRejecting && (
<div className="flex gap-2 shrink-0">
<Button
variant="primary"
onClick={() => handleAccept(request.id)}
disabled={isProcessing}
className="px-3 py-1.5 text-xs"
>
<Check className="w-3 h-3 mr-1" />
{isProcessing ? 'Accepting...' : 'Accept'}
</Button>
<Button
variant="secondary"
onClick={() => setRejectModalId(request.id)}
disabled={isProcessing}
className="px-3 py-1.5 text-xs"
>
<X className="w-3 h-3 mr-1" />
Reject
</Button>
</div>
)}
</div>
</div>
);
})}
</div>
<div className="text-xs text-gray-500 mt-4">
<p>
<strong className="text-gray-400">Note:</strong> Accepting a request will activate the sponsorship.
The sponsor will be charged and you'll receive the payment minus 10% platform fee.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,571 @@
'use client';
import React, { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
Eye,
TrendingUp,
Users,
Star,
Target,
DollarSign,
Calendar,
Trophy,
Zap,
ExternalLink,
MessageCircle,
Activity,
Shield,
Check,
Loader2,
} from 'lucide-react';
import { getApplyForSponsorshipUseCase, getSponsorRepository } from '@/lib/di-container';
// ============================================================================
// TYPES
// ============================================================================
export type EntityType = 'league' | 'race' | 'driver' | 'team';
export interface SponsorMetric {
icon: React.ElementType;
label: string;
value: string | number;
color?: string;
trend?: {
value: number;
isPositive: boolean;
};
}
export interface SponsorshipSlot {
tier: 'main' | 'secondary';
available: boolean;
price: number;
currency?: string;
benefits: string[];
}
export interface SponsorInsightsProps {
// Entity info
entityType: EntityType;
entityId: string;
entityName: string;
// Tier classification
tier: 'premium' | 'standard' | 'starter';
// Key metrics (shown in grid)
metrics: SponsorMetric[];
// Sponsorship availability
slots: SponsorshipSlot[];
// Optional: additional stats section
additionalStats?: {
label: string;
items: Array<{ label: string; value: string | number }>;
};
// Optional: trust indicators
trustScore?: number;
discordMembers?: number;
monthlyActivity?: number;
// CTA customization
ctaLabel?: string;
ctaHref?: string;
// Optional: current sponsor ID (if logged in as sponsor)
currentSponsorId?: string;
// Optional: callback when sponsorship request is submitted
onSponsorshipRequested?: (tier: 'main' | 'secondary') => void;
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
function getTierStyles(tier: SponsorInsightsProps['tier']) {
switch (tier) {
case 'premium':
return {
badge: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
gradient: 'from-yellow-500/10 via-transparent to-transparent',
};
case 'standard':
return {
badge: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
gradient: 'from-blue-500/10 via-transparent to-transparent',
};
default:
return {
badge: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
gradient: 'from-gray-500/10 via-transparent to-transparent',
};
}
}
function getEntityLabel(type: EntityType): string {
switch (type) {
case 'league': return 'League';
case 'race': return 'Race';
case 'driver': return 'Driver';
case 'team': return 'Team';
}
}
function getEntityIcon(type: EntityType) {
switch (type) {
case 'league': return Trophy;
case 'race': return Zap;
case 'driver': return Users;
case 'team': return Users;
}
}
// ============================================================================
// COMPONENT
// ============================================================================
export default function SponsorInsightsCard({
entityType,
entityId,
entityName,
tier,
metrics,
slots,
additionalStats,
trustScore,
discordMembers,
monthlyActivity,
ctaLabel,
ctaHref,
currentSponsorId,
onSponsorshipRequested,
}: SponsorInsightsProps) {
const router = useRouter();
const tierStyles = getTierStyles(tier);
const EntityIcon = getEntityIcon(entityType);
// State for sponsorship application
const [applyingTier, setApplyingTier] = useState<'main' | 'secondary' | null>(null);
const [appliedTiers, setAppliedTiers] = useState<Set<'main' | 'secondary'>>(new Set());
const [error, setError] = useState<string | null>(null);
const mainSlot = slots.find(s => s.tier === 'main');
const secondarySlots = slots.filter(s => s.tier === 'secondary');
const availableSecondary = secondarySlots.filter(s => s.available).length;
// Map EntityType to SponsorableEntityType
const getSponsorableEntityType = useCallback((type: EntityType): 'driver' | 'team' | 'race' | 'season' => {
switch (type) {
case 'league': return 'season'; // Leagues are sponsored via their seasons
case 'race': return 'race';
case 'driver': return 'driver';
case 'team': return 'team';
}
}, []);
const handleSponsorClick = useCallback(async (slotTier: 'main' | 'secondary') => {
// If no sponsor ID, redirect to sponsor signup/login
if (!currentSponsorId) {
const href = ctaHref || `/sponsor/${entityType}s/${entityId}?tier=${slotTier}`;
router.push(href);
return;
}
// If already applied for this tier, show details page
if (appliedTiers.has(slotTier)) {
router.push(`/sponsor/dashboard`);
return;
}
// Apply for sponsorship using use case
setApplyingTier(slotTier);
setError(null);
try {
const applyUseCase = getApplyForSponsorshipUseCase();
const slot = slotTier === 'main' ? mainSlot : secondarySlots[0];
const slotPrice = slot?.price ?? 0;
await applyUseCase.execute({
sponsorId: currentSponsorId,
entityType: getSponsorableEntityType(entityType),
entityId,
tier: slotTier,
offeredAmount: slotPrice * 100, // Convert to cents
currency: (slot?.currency as 'USD' | 'EUR' | 'GBP') ?? 'USD',
message: `Interested in sponsoring ${entityName} as ${slotTier} sponsor.`,
});
// Mark as applied
setAppliedTiers(prev => new Set([...prev, slotTier]));
// Call callback if provided
onSponsorshipRequested?.(slotTier);
} catch (err) {
console.error('Failed to apply for sponsorship:', err);
setError(err instanceof Error ? err.message : 'Failed to submit sponsorship request');
} finally {
setApplyingTier(null);
}
}, [currentSponsorId, ctaHref, entityType, entityId, entityName, router, mainSlot, secondarySlots, appliedTiers, getSponsorableEntityType, onSponsorshipRequested]);
return (
<Card className={`mb-6 border-primary-blue/30 bg-gradient-to-r ${tierStyles.gradient}`}>
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<Target className="w-5 h-5 text-primary-blue" />
<h3 className="text-lg font-semibold text-white">Sponsorship Opportunity</h3>
</div>
<p className="text-gray-400 text-sm">
Reach engaged sim racers by sponsoring this {entityType}
</p>
</div>
<div className="flex items-center gap-2">
<div className={`px-2 py-1 rounded text-xs font-medium border ${tierStyles.badge}`}>
{tier.charAt(0).toUpperCase() + tier.slice(1)} {getEntityLabel(entityType)}
</div>
</div>
</div>
{/* Key Metrics Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
{metrics.slice(0, 4).map((metric, index) => {
const Icon = metric.icon;
return (
<div
key={index}
className="bg-iron-gray/50 rounded-lg p-3 border border-charcoal-outline"
>
<div className={`flex items-center gap-1.5 ${metric.color || 'text-primary-blue'} mb-1`}>
<Icon className="w-4 h-4" />
<span className="text-xs font-medium">{metric.label}</span>
</div>
<div className="flex items-baseline gap-2">
<div className="text-xl font-bold text-white">
{typeof metric.value === 'number' ? metric.value.toLocaleString() : metric.value}
</div>
{metric.trend && (
<span className={`text-xs ${metric.trend.isPositive ? 'text-performance-green' : 'text-red-400'}`}>
{metric.trend.isPositive ? '+' : ''}{metric.trend.value}%
</span>
)}
</div>
</div>
);
})}
</div>
{/* Trust & Activity Indicators */}
{(trustScore !== undefined || discordMembers !== undefined || monthlyActivity !== undefined) && (
<div className="flex flex-wrap gap-4 mb-4 pb-4 border-b border-charcoal-outline/50">
{trustScore !== undefined && (
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-performance-green" />
<span className="text-sm text-gray-400">Trust Score:</span>
<span className="text-sm font-semibold text-white">{trustScore}/100</span>
</div>
)}
{discordMembers !== undefined && (
<div className="flex items-center gap-2">
<MessageCircle className="w-4 h-4 text-purple-400" />
<span className="text-sm text-gray-400">Discord:</span>
<span className="text-sm font-semibold text-white">{discordMembers.toLocaleString()}</span>
</div>
)}
{monthlyActivity !== undefined && (
<div className="flex items-center gap-2">
<Activity className="w-4 h-4 text-neon-aqua" />
<span className="text-sm text-gray-400">Monthly Activity:</span>
<span className="text-sm font-semibold text-white">{monthlyActivity}%</span>
</div>
)}
</div>
)}
{/* Sponsorship Slots */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
{/* Main Sponsor Slot */}
{mainSlot && (
<div className={`p-3 rounded-lg border ${
mainSlot.available
? 'bg-performance-green/10 border-performance-green/30'
: 'bg-iron-gray/30 border-charcoal-outline'
}`}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-white">Main Sponsor Slot</span>
<span className={`text-xs ${mainSlot.available ? 'text-performance-green' : 'text-gray-500'}`}>
{mainSlot.available ? 'Available' : 'Taken'}
</span>
</div>
<p className="text-xs text-gray-400 mb-2">{mainSlot.benefits.join(' • ')}</p>
{mainSlot.available && (
<div className="flex items-center justify-between">
<span className="text-lg font-bold text-white">
${mainSlot.price.toLocaleString()}/season
</span>
<Button
variant="primary"
onClick={() => handleSponsorClick('main')}
disabled={applyingTier === 'main'}
className="text-xs px-3 py-1"
>
{applyingTier === 'main' ? (
<>
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
Applying...
</>
) : appliedTiers.has('main') ? (
<>
<Check className="w-3 h-3 mr-1" />
Applied
</>
) : (
'Apply to Sponsor'
)}
</Button>
</div>
)}
</div>
)}
{/* Secondary Slots */}
{secondarySlots.length > 0 && (
<div className={`p-3 rounded-lg border ${
availableSecondary > 0
? 'bg-purple-500/10 border-purple-500/30'
: 'bg-iron-gray/30 border-charcoal-outline'
}`}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-white">Secondary Slots</span>
<span className={`text-xs ${availableSecondary > 0 ? 'text-purple-400' : 'text-gray-500'}`}>
{availableSecondary}/{secondarySlots.length} Available
</span>
</div>
<p className="text-xs text-gray-400 mb-2">
{secondarySlots[0]?.benefits.join(' • ') || 'Logo placement on page'}
</p>
{availableSecondary > 0 && (
<div className="flex items-center justify-between">
<span className="text-lg font-bold text-white">
${secondarySlots[0]?.price.toLocaleString()}/season
</span>
<Button
variant="secondary"
onClick={() => handleSponsorClick('secondary')}
disabled={applyingTier === 'secondary'}
className="text-xs px-3 py-1"
>
{applyingTier === 'secondary' ? (
<>
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
Applying...
</>
) : appliedTiers.has('secondary') ? (
<>
<Check className="w-3 h-3 mr-1" />
Applied
</>
) : (
'Apply to Sponsor'
)}
</Button>
</div>
)}
</div>
)}
</div>
{/* Additional Stats */}
{additionalStats && (
<div className="mb-4 pb-4 border-b border-charcoal-outline/50">
<h4 className="text-sm font-medium text-gray-400 mb-2">{additionalStats.label}</h4>
<div className="flex flex-wrap gap-4">
{additionalStats.items.map((item, index) => (
<div key={index} className="flex items-center gap-2">
<span className="text-sm text-gray-500">{item.label}:</span>
<span className="text-sm font-semibold text-white">
{typeof item.value === 'number' ? item.value.toLocaleString() : item.value}
</span>
</div>
))}
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between pt-3 border-t border-charcoal-outline/50">
<p className="text-xs text-gray-500">
10% platform fee applies Logos burned on all liveries
{appliedTiers.size > 0 && ' • Application pending review'}
</p>
<Button
variant="secondary"
onClick={() => router.push(ctaHref || `/sponsor/${entityType}s/${entityId}`)}
className="text-xs"
>
{ctaLabel || 'View Full Details'}
</Button>
</div>
</Card>
);
}
// ============================================================================
// HELPER HOOK: useSponsorMode
// ============================================================================
export function useSponsorMode(): boolean {
const [isSponsor, setIsSponsor] = React.useState(false);
React.useEffect(() => {
if (typeof document !== 'undefined') {
const cookies = document.cookie.split(';');
const demoModeCookie = cookies.find(c => c.trim().startsWith('gridpilot_demo_mode='));
if (demoModeCookie) {
const value = demoModeCookie.split('=')[1]?.trim();
setIsSponsor(value === 'sponsor');
}
}
}, []);
return isSponsor;
}
// ============================================================================
// COMMON METRIC BUILDERS
// ============================================================================
export const MetricBuilders = {
views: (value: number, label = 'Views'): SponsorMetric => ({
icon: Eye,
label,
value,
color: 'text-primary-blue',
}),
engagement: (value: number | string): SponsorMetric => ({
icon: TrendingUp,
label: 'Engagement',
value: typeof value === 'number' ? `${value}%` : value,
color: 'text-performance-green',
}),
reach: (value: number): SponsorMetric => ({
icon: Users,
label: 'Est. Reach',
value,
color: 'text-purple-400',
}),
rating: (value: number | string, label = 'Rating'): SponsorMetric => ({
icon: Star,
label,
value,
color: 'text-warning-amber',
}),
races: (value: number): SponsorMetric => ({
icon: Calendar,
label: 'Races',
value,
color: 'text-neon-aqua',
}),
members: (value: number): SponsorMetric => ({
icon: Users,
label: 'Members',
value,
color: 'text-purple-400',
}),
impressions: (value: number): SponsorMetric => ({
icon: Eye,
label: 'Impressions',
value,
color: 'text-primary-blue',
}),
sof: (value: number | string): SponsorMetric => ({
icon: Zap,
label: 'Avg SOF',
value,
color: 'text-warning-amber',
}),
};
// ============================================================================
// SLOT TEMPLATES
// ============================================================================
export const SlotTemplates = {
league: (mainAvailable: boolean, secondaryAvailable: number, mainPrice: number, secondaryPrice: number): SponsorshipSlot[] => [
{
tier: 'main',
available: mainAvailable,
price: mainPrice,
benefits: ['Hood placement', 'League banner', 'Prominent logo'],
},
{
tier: 'secondary',
available: secondaryAvailable > 0,
price: secondaryPrice,
benefits: ['Side logo placement', 'League page listing'],
},
{
tier: 'secondary',
available: secondaryAvailable > 1,
price: secondaryPrice,
benefits: ['Side logo placement', 'League page listing'],
},
],
race: (mainAvailable: boolean, mainPrice: number): SponsorshipSlot[] => [
{
tier: 'main',
available: mainAvailable,
price: mainPrice,
benefits: ['Race title sponsor', 'Stream overlay', 'Results banner'],
},
],
driver: (available: boolean, price: number): SponsorshipSlot[] => [
{
tier: 'main',
available,
price,
benefits: ['Suit logo', 'Helmet branding', 'Social mentions'],
},
],
team: (mainAvailable: boolean, secondaryAvailable: boolean, mainPrice: number, secondaryPrice: number): SponsorshipSlot[] => [
{
tier: 'main',
available: mainAvailable,
price: mainPrice,
benefits: ['Team name suffix', 'Car livery', 'All driver suits'],
},
{
tier: 'secondary',
available: secondaryAvailable,
price: secondaryPrice,
benefits: ['Team page logo', 'Minor livery placement'],
},
],
};

View File

@@ -7,6 +7,9 @@ import { Penalty } from '@gridpilot/racing/domain/entities/Penalty';
import { Protest } from '@gridpilot/racing/domain/entities/Protest';
import { Game } from '@gridpilot/racing/domain/entities/Game';
import { Season } from '@gridpilot/racing/domain/entities/Season';
import { Sponsor } from '@gridpilot/racing/domain/entities/Sponsor';
import { SeasonSponsorship } from '@gridpilot/racing/domain/entities/SeasonSponsorship';
import { Money } from '@gridpilot/racing/domain/value-objects/Money';
import type { LeagueMembership, JoinRequest } from '@gridpilot/racing/domain/entities/LeagueMembership';
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
@@ -25,6 +28,10 @@ import type {
ITeamRepository,
ITeamMembershipRepository,
IRaceRegistrationRepository,
ISponsorRepository,
ISeasonSponsorshipRepository,
ISponsorshipRequestRepository,
ISponsorshipPricingRepository,
} from '@gridpilot/racing';
import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
import type { IFeedRepository } from '@gridpilot/social/domain/repositories/IFeedRepository';
@@ -62,6 +69,10 @@ import {
getLeagueScoringPresetById,
} from '@gridpilot/racing/infrastructure/repositories/InMemoryScoringRepositories';
import { InMemoryLeagueScoringPresetProvider } from '@gridpilot/racing/infrastructure/repositories/InMemoryLeagueScoringPresetProvider';
import { InMemorySponsorRepository } from '@gridpilot/racing/infrastructure/repositories/InMemorySponsorRepository';
import { InMemorySeasonSponsorshipRepository } from '@gridpilot/racing/infrastructure/repositories/InMemorySeasonSponsorshipRepository';
import { InMemorySponsorshipRequestRepository } from '@gridpilot/racing/infrastructure/repositories/InMemorySponsorshipRequestRepository';
import { InMemorySponsorshipPricingRepository } from '@gridpilot/racing/infrastructure/repositories/InMemorySponsorshipPricingRepository';
import { InMemoryTeamRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTeamRepository';
import { InMemoryTeamMembershipRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTeamMembershipRepository';
import { InMemoryRaceRegistrationRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRegistrationRepository';
@@ -107,6 +118,13 @@ import {
GetRacePenaltiesQuery,
RequestProtestDefenseUseCase,
SubmitProtestDefenseUseCase,
GetSponsorDashboardQuery,
GetSponsorSponsorshipsQuery,
GetPendingSponsorshipRequestsQuery,
GetEntitySponsorshipPricingQuery,
ApplyForSponsorshipUseCase,
AcceptSponsorshipRequestUseCase,
RejectSponsorshipRequestUseCase,
} from '@gridpilot/racing/application';
import { TransferLeagueOwnershipUseCase } from '@gridpilot/racing/application/use-cases/TransferLeagueOwnershipUseCase';
import type { DriverRatingProvider } from '@gridpilot/racing/application';
@@ -571,6 +589,74 @@ export function configureDIContainer(): void {
new InMemoryCarRepository(DEMO_CARS)
);
// Sponsor repositories - seed with demo sponsors
const seededSponsors = seedData.sponsors.map(s =>
Sponsor.create({
id: s.id,
name: s.name,
contactEmail: s.contactEmail,
logoUrl: s.logoUrl,
websiteUrl: s.websiteUrl,
})
);
const sponsorRepo = new InMemorySponsorRepository();
// Use synchronous seeding via internal method
seededSponsors.forEach(sponsor => {
(sponsorRepo as any).sponsors.set(sponsor.id, sponsor);
});
container.registerInstance<ISponsorRepository>(
DI_TOKENS.SponsorRepository,
sponsorRepo
);
const seededSponsorships = seedData.seasonSponsorships.map(ss =>
SeasonSponsorship.create({
id: ss.id,
seasonId: ss.seasonId,
sponsorId: ss.sponsorId,
tier: ss.tier,
pricing: Money.create(ss.pricingAmount, ss.pricingCurrency),
status: ss.status,
description: ss.description,
})
);
const seasonSponsorshipRepo = new InMemorySeasonSponsorshipRepository();
// Use synchronous seeding via internal method
seededSponsorships.forEach(sponsorship => {
(seasonSponsorshipRepo as any).sponsorships.set(sponsorship.id, sponsorship);
});
container.registerInstance<ISeasonSponsorshipRepository>(
DI_TOKENS.SeasonSponsorshipRepository,
seasonSponsorshipRepo
);
// Sponsorship Request and Pricing repositories
const sponsorshipRequestRepo = new InMemorySponsorshipRequestRepository();
container.registerInstance<ISponsorshipRequestRepository>(
DI_TOKENS.SponsorshipRequestRepository,
sponsorshipRequestRepo
);
const sponsorshipPricingRepo = new InMemorySponsorshipPricingRepository();
// Seed sponsorship pricings from demo data
seedData.sponsorshipPricings?.forEach(pricing => {
(sponsorshipPricingRepo as any).pricings.set(
`${pricing.entityType}-${pricing.entityId}`,
pricing
);
});
container.registerInstance<ISponsorshipPricingRepository>(
DI_TOKENS.SponsorshipPricingRepository,
sponsorshipPricingRepo
);
// Seed sponsorship requests from demo data
seedData.sponsorshipRequests?.forEach(request => {
(sponsorshipRequestRepo as any).requests.set(request.id, request);
});
// Social repositories
container.registerInstance<IFeedRepository>(
DI_TOKENS.FeedRepository,
@@ -908,6 +994,74 @@ export function configureDIContainer(): void {
DI_TOKENS.GetUnreadNotificationsQuery,
new GetUnreadNotificationsQuery(notificationRepository)
);
// Register queries - Sponsors
const sponsorRepository = container.resolve<ISponsorRepository>(DI_TOKENS.SponsorRepository);
const seasonSponsorshipRepository = container.resolve<ISeasonSponsorshipRepository>(DI_TOKENS.SeasonSponsorshipRepository);
container.registerInstance(
DI_TOKENS.GetSponsorDashboardQuery,
new GetSponsorDashboardQuery(
sponsorRepository,
seasonSponsorshipRepository,
seasonRepository,
leagueRepository,
leagueMembershipRepository,
raceRepository
)
);
container.registerInstance(
DI_TOKENS.GetSponsorSponsorshipsQuery,
new GetSponsorSponsorshipsQuery(
sponsorRepository,
seasonSponsorshipRepository,
seasonRepository,
leagueRepository,
leagueMembershipRepository,
raceRepository
)
);
// Sponsorship request repositories and use cases
const sponsorshipRequestRepository = container.resolve<ISponsorshipRequestRepository>(DI_TOKENS.SponsorshipRequestRepository);
const sponsorshipPricingRepository = container.resolve<ISponsorshipPricingRepository>(DI_TOKENS.SponsorshipPricingRepository);
container.registerInstance(
DI_TOKENS.GetPendingSponsorshipRequestsQuery,
new GetPendingSponsorshipRequestsQuery(
sponsorshipRequestRepository,
sponsorRepository
)
);
container.registerInstance(
DI_TOKENS.GetEntitySponsorshipPricingQuery,
new GetEntitySponsorshipPricingQuery(
sponsorshipPricingRepository,
sponsorshipRequestRepository,
seasonSponsorshipRepository
)
);
container.registerInstance(
DI_TOKENS.ApplyForSponsorshipUseCase,
new ApplyForSponsorshipUseCase(
sponsorshipRequestRepository,
sponsorshipPricingRepository,
sponsorRepository
)
);
container.registerInstance(
DI_TOKENS.AcceptSponsorshipRequestUseCase,
new AcceptSponsorshipRequestUseCase(sponsorshipRequestRepository, seasonSponsorshipRepository)
);
container.registerInstance(
DI_TOKENS.RejectSponsorshipRequestUseCase,
new RejectSponsorshipRequestUseCase(sponsorshipRequestRepository)
);
}
/**

View File

@@ -71,7 +71,18 @@ import type {
GetRacePenaltiesQuery,
RequestProtestDefenseUseCase,
SubmitProtestDefenseUseCase,
GetSponsorDashboardQuery,
GetSponsorSponsorshipsQuery,
ApplyForSponsorshipUseCase,
AcceptSponsorshipRequestUseCase,
RejectSponsorshipRequestUseCase,
GetPendingSponsorshipRequestsQuery,
GetEntitySponsorshipPricingQuery,
} from '@gridpilot/racing/application';
import type { ISponsorRepository } from '@gridpilot/racing/domain/repositories/ISponsorRepository';
import type { ISeasonSponsorshipRepository } from '@gridpilot/racing/domain/repositories/ISeasonSponsorshipRepository';
import type { ISponsorshipRequestRepository } from '@gridpilot/racing/domain/repositories/ISponsorshipRequestRepository';
import type { ISponsorshipPricingRepository } from '@gridpilot/racing/domain/repositories/ISponsorshipPricingRepository';
import type { TransferLeagueOwnershipUseCase } from '@gridpilot/racing/application/use-cases/TransferLeagueOwnershipUseCase';
import type { DriverRatingProvider } from '@gridpilot/racing/application';
import type { PreviewLeagueScheduleQuery } from '@gridpilot/racing/application';
@@ -424,6 +435,61 @@ class DIContainer {
this.ensureInitialized();
return getDIContainer().resolve<TransferLeagueOwnershipUseCase>(DI_TOKENS.TransferLeagueOwnershipUseCase);
}
get sponsorRepository(): ISponsorRepository {
this.ensureInitialized();
return getDIContainer().resolve<ISponsorRepository>(DI_TOKENS.SponsorRepository);
}
get seasonSponsorshipRepository(): ISeasonSponsorshipRepository {
this.ensureInitialized();
return getDIContainer().resolve<ISeasonSponsorshipRepository>(DI_TOKENS.SeasonSponsorshipRepository);
}
get getSponsorDashboardQuery(): GetSponsorDashboardQuery {
this.ensureInitialized();
return getDIContainer().resolve<GetSponsorDashboardQuery>(DI_TOKENS.GetSponsorDashboardQuery);
}
get getSponsorSponsorshipsQuery(): GetSponsorSponsorshipsQuery {
this.ensureInitialized();
return getDIContainer().resolve<GetSponsorSponsorshipsQuery>(DI_TOKENS.GetSponsorSponsorshipsQuery);
}
get sponsorshipRequestRepository(): ISponsorshipRequestRepository {
this.ensureInitialized();
return getDIContainer().resolve<ISponsorshipRequestRepository>(DI_TOKENS.SponsorshipRequestRepository);
}
get sponsorshipPricingRepository(): ISponsorshipPricingRepository {
this.ensureInitialized();
return getDIContainer().resolve<ISponsorshipPricingRepository>(DI_TOKENS.SponsorshipPricingRepository);
}
get applyForSponsorshipUseCase(): ApplyForSponsorshipUseCase {
this.ensureInitialized();
return getDIContainer().resolve<ApplyForSponsorshipUseCase>(DI_TOKENS.ApplyForSponsorshipUseCase);
}
get acceptSponsorshipRequestUseCase(): AcceptSponsorshipRequestUseCase {
this.ensureInitialized();
return getDIContainer().resolve<AcceptSponsorshipRequestUseCase>(DI_TOKENS.AcceptSponsorshipRequestUseCase);
}
get rejectSponsorshipRequestUseCase(): RejectSponsorshipRequestUseCase {
this.ensureInitialized();
return getDIContainer().resolve<RejectSponsorshipRequestUseCase>(DI_TOKENS.RejectSponsorshipRequestUseCase);
}
get getPendingSponsorshipRequestsQuery(): GetPendingSponsorshipRequestsQuery {
this.ensureInitialized();
return getDIContainer().resolve<GetPendingSponsorshipRequestsQuery>(DI_TOKENS.GetPendingSponsorshipRequestsQuery);
}
get getEntitySponsorshipPricingQuery(): GetEntitySponsorshipPricingQuery {
this.ensureInitialized();
return getDIContainer().resolve<GetEntitySponsorshipPricingQuery>(DI_TOKENS.GetEntitySponsorshipPricingQuery);
}
}
/**
@@ -606,6 +672,10 @@ export function getCarRepository(): ICarRepository {
return DIContainer.getInstance().carRepository;
}
export function getSeasonRepository(): ISeasonRepository {
return DIContainer.getInstance().seasonRepository;
}
export function getNotificationRepository(): INotificationRepository {
return DIContainer.getInstance().notificationRepository;
}
@@ -658,6 +728,50 @@ export function getTransferLeagueOwnershipUseCase(): TransferLeagueOwnershipUseC
return DIContainer.getInstance().transferLeagueOwnershipUseCase;
}
export function getSponsorRepository(): ISponsorRepository {
return DIContainer.getInstance().sponsorRepository;
}
export function getSeasonSponsorshipRepository(): ISeasonSponsorshipRepository {
return DIContainer.getInstance().seasonSponsorshipRepository;
}
export function getGetSponsorDashboardQuery(): GetSponsorDashboardQuery {
return DIContainer.getInstance().getSponsorDashboardQuery;
}
export function getGetSponsorSponsorshipsQuery(): GetSponsorSponsorshipsQuery {
return DIContainer.getInstance().getSponsorSponsorshipsQuery;
}
export function getSponsorshipRequestRepository(): ISponsorshipRequestRepository {
return DIContainer.getInstance().sponsorshipRequestRepository;
}
export function getSponsorshipPricingRepository(): ISponsorshipPricingRepository {
return DIContainer.getInstance().sponsorshipPricingRepository;
}
export function getApplyForSponsorshipUseCase(): ApplyForSponsorshipUseCase {
return DIContainer.getInstance().applyForSponsorshipUseCase;
}
export function getAcceptSponsorshipRequestUseCase(): AcceptSponsorshipRequestUseCase {
return DIContainer.getInstance().acceptSponsorshipRequestUseCase;
}
export function getRejectSponsorshipRequestUseCase(): RejectSponsorshipRequestUseCase {
return DIContainer.getInstance().rejectSponsorshipRequestUseCase;
}
export function getGetPendingSponsorshipRequestsQuery(): GetPendingSponsorshipRequestsQuery {
return DIContainer.getInstance().getPendingSponsorshipRequestsQuery;
}
export function getGetEntitySponsorshipPricingQuery(): GetEntitySponsorshipPricingQuery {
return DIContainer.getInstance().getEntitySponsorshipPricingQuery;
}
/**
* Reset function for testing
*/

View File

@@ -24,6 +24,10 @@ export const DI_TOKENS = {
SocialRepository: Symbol.for('ISocialGraphRepository'),
NotificationRepository: Symbol.for('INotificationRepository'),
NotificationPreferenceRepository: Symbol.for('INotificationPreferenceRepository'),
SponsorRepository: Symbol.for('ISponsorRepository'),
SeasonSponsorshipRepository: Symbol.for('ISeasonSponsorshipRepository'),
SponsorshipRequestRepository: Symbol.for('ISponsorshipRequestRepository'),
SponsorshipPricingRepository: Symbol.for('ISponsorshipPricingRepository'),
// Providers
LeagueScoringPresetProvider: Symbol.for('LeagueScoringPresetProvider'),
@@ -87,6 +91,17 @@ export const DI_TOKENS = {
// Queries - Notifications
GetUnreadNotificationsQuery: Symbol.for('GetUnreadNotificationsQuery'),
// Queries - Sponsors
GetSponsorDashboardQuery: Symbol.for('GetSponsorDashboardQuery'),
GetSponsorSponsorshipsQuery: Symbol.for('GetSponsorSponsorshipsQuery'),
GetPendingSponsorshipRequestsQuery: Symbol.for('GetPendingSponsorshipRequestsQuery'),
GetEntitySponsorshipPricingQuery: Symbol.for('GetEntitySponsorshipPricingQuery'),
// Use Cases - Sponsorship
ApplyForSponsorshipUseCase: Symbol.for('ApplyForSponsorshipUseCase'),
AcceptSponsorshipRequestUseCase: Symbol.for('AcceptSponsorshipRequestUseCase'),
RejectSponsorshipRequestUseCase: Symbol.for('RejectSponsorshipRequestUseCase'),
// Data
DriverStats: Symbol.for('DriverStats'),
} as const;