wip
This commit is contained in:
171
apps/website/app/api/payments/membership-fees/route.ts
Normal file
171
apps/website/app/api/payments/membership-fees/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
180
apps/website/app/api/payments/prizes/route.ts
Normal file
180
apps/website/app/api/payments/prizes/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
141
apps/website/app/api/payments/route.ts
Normal file
141
apps/website/app/api/payments/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
142
apps/website/app/api/payments/wallets/route.ts
Normal file
142
apps/website/app/api/payments/wallets/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
34
apps/website/app/api/sponsors/dashboard/route.ts
Normal file
34
apps/website/app/api/sponsors/dashboard/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
50
apps/website/app/api/sponsors/route.ts
Normal file
50
apps/website/app/api/sponsors/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
34
apps/website/app/api/sponsors/sponsorships/route.ts
Normal file
34
apps/website/app/api/sponsors/sponsorships/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
202
apps/website/app/api/wallets/[leagueId]/withdraw/route.ts
Normal file
202
apps/website/app/api/wallets/[leagueId]/withdraw/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
105
apps/website/app/leagues/[id]/sponsorships/page.tsx
Normal file
105
apps/website/app/leagues/[id]/sponsorships/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
483
apps/website/app/leagues/[id]/wallet/page.tsx
Normal file
483
apps/website/app/leagues/[id]/wallet/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
};
|
||||
|
||||
|
||||
164
apps/website/app/profile/liveries/page.tsx
Normal file
164
apps/website/app/profile/liveries/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
405
apps/website/app/profile/liveries/upload/page.tsx
Normal file
405
apps/website/app/profile/liveries/upload/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
303
apps/website/app/profile/sponsorship-requests/page.tsx
Normal file
303
apps/website/app/profile/sponsorship-requests/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}`}>
|
||||
|
||||
267
apps/website/app/sponsor/billing/page.tsx
Normal file
267
apps/website/app/sponsor/billing/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
278
apps/website/app/sponsor/campaigns/page.tsx
Normal file
278
apps/website/app/sponsor/campaigns/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
404
apps/website/app/sponsor/dashboard/page.tsx
Normal file
404
apps/website/app/sponsor/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
311
apps/website/app/sponsor/leagues/[id]/page.tsx
Normal file
311
apps/website/app/sponsor/leagues/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
298
apps/website/app/sponsor/leagues/page.tsx
Normal file
298
apps/website/app/sponsor/leagues/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/website/app/sponsor/page.tsx
Normal file
5
apps/website/app/sponsor/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function SponsorPage() {
|
||||
redirect('/sponsor/dashboard');
|
||||
}
|
||||
333
apps/website/app/sponsor/settings/page.tsx
Normal file
333
apps/website/app/sponsor/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
429
apps/website/app/sponsor/signup/page.tsx
Normal file
429
apps/website/app/sponsor/signup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
417
apps/website/components/leagues/LeagueDecalPlacementEditor.tsx
Normal file
417
apps/website/components/leagues/LeagueDecalPlacementEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
239
apps/website/components/leagues/LeagueMembershipFeesSection.tsx
Normal file
239
apps/website/components/leagues/LeagueMembershipFeesSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
315
apps/website/components/leagues/LeagueSponsorshipsSection.tsx
Normal file
315
apps/website/components/leagues/LeagueSponsorshipsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
241
apps/website/components/sponsors/PendingSponsorshipRequests.tsx
Normal file
241
apps/website/components/sponsors/PendingSponsorshipRequests.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
571
apps/website/components/sponsors/SponsorInsightsCard.tsx
Normal file
571
apps/website/components/sponsors/SponsorInsightsCard.tsx
Normal 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'],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Query: GetEntityAnalyticsQuery
|
||||
*
|
||||
* Retrieves analytics data for an entity (league, driver, team, race).
|
||||
* Returns metrics formatted for display to sponsors and admins.
|
||||
*/
|
||||
|
||||
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
|
||||
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
|
||||
import type { IAnalyticsSnapshotRepository } from '../../domain/repositories/IAnalyticsSnapshotRepository';
|
||||
import type { EntityType } from '../../domain/entities/PageView';
|
||||
import type { SnapshotPeriod } from '../../domain/entities/AnalyticsSnapshot';
|
||||
|
||||
export interface GetEntityAnalyticsInput {
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
period?: SnapshotPeriod;
|
||||
since?: Date;
|
||||
}
|
||||
|
||||
export interface EntityAnalyticsOutput {
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
summary: {
|
||||
totalPageViews: number;
|
||||
uniqueVisitors: number;
|
||||
sponsorClicks: number;
|
||||
engagementScore: number;
|
||||
trustIndicator: 'high' | 'medium' | 'low';
|
||||
exposureValue: number;
|
||||
};
|
||||
trends: {
|
||||
pageViewsChange: number;
|
||||
uniqueVisitorsChange: number;
|
||||
engagementChange: number;
|
||||
};
|
||||
period: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
label: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class GetEntityAnalyticsQuery {
|
||||
constructor(
|
||||
private readonly pageViewRepository: IPageViewRepository,
|
||||
private readonly engagementRepository: IEngagementRepository,
|
||||
private readonly snapshotRepository: IAnalyticsSnapshotRepository
|
||||
) {}
|
||||
|
||||
async execute(input: GetEntityAnalyticsInput): Promise<EntityAnalyticsOutput> {
|
||||
const period = input.period ?? 'weekly';
|
||||
const now = new Date();
|
||||
const since = input.since ?? this.getPeriodStartDate(now, period);
|
||||
|
||||
// Get current metrics
|
||||
const totalPageViews = await this.pageViewRepository.countByEntityId(
|
||||
input.entityType,
|
||||
input.entityId,
|
||||
since
|
||||
);
|
||||
|
||||
const uniqueVisitors = await this.pageViewRepository.countUniqueVisitors(
|
||||
input.entityType,
|
||||
input.entityId,
|
||||
since
|
||||
);
|
||||
|
||||
const sponsorClicks = await this.engagementRepository.getSponsorClicksForEntity(
|
||||
input.entityId,
|
||||
since
|
||||
);
|
||||
|
||||
// Calculate engagement score (weighted sum of actions)
|
||||
const engagementScore = await this.calculateEngagementScore(input.entityId, since);
|
||||
|
||||
// Determine trust indicator
|
||||
const trustIndicator = this.determineTrustIndicator(totalPageViews, uniqueVisitors, engagementScore);
|
||||
|
||||
// Calculate exposure value (for sponsor ROI)
|
||||
const exposureValue = this.calculateExposureValue(totalPageViews, uniqueVisitors, sponsorClicks);
|
||||
|
||||
// Get previous period for trends
|
||||
const previousPeriodStart = this.getPreviousPeriodStart(since, period);
|
||||
const previousPageViews = await this.pageViewRepository.countByEntityId(
|
||||
input.entityType,
|
||||
input.entityId,
|
||||
previousPeriodStart
|
||||
) - totalPageViews;
|
||||
|
||||
const previousUniqueVisitors = await this.pageViewRepository.countUniqueVisitors(
|
||||
input.entityType,
|
||||
input.entityId,
|
||||
previousPeriodStart
|
||||
) - uniqueVisitors;
|
||||
|
||||
return {
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
summary: {
|
||||
totalPageViews,
|
||||
uniqueVisitors,
|
||||
sponsorClicks,
|
||||
engagementScore,
|
||||
trustIndicator,
|
||||
exposureValue,
|
||||
},
|
||||
trends: {
|
||||
pageViewsChange: this.calculatePercentageChange(previousPageViews, totalPageViews),
|
||||
uniqueVisitorsChange: this.calculatePercentageChange(previousUniqueVisitors, uniqueVisitors),
|
||||
engagementChange: 0, // Would need historical engagement data
|
||||
},
|
||||
period: {
|
||||
start: since,
|
||||
end: now,
|
||||
label: this.formatPeriodLabel(since, now),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private getPeriodStartDate(now: Date, period: SnapshotPeriod): Date {
|
||||
const start = new Date(now);
|
||||
switch (period) {
|
||||
case 'daily':
|
||||
start.setDate(start.getDate() - 1);
|
||||
break;
|
||||
case 'weekly':
|
||||
start.setDate(start.getDate() - 7);
|
||||
break;
|
||||
case 'monthly':
|
||||
start.setMonth(start.getMonth() - 1);
|
||||
break;
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
private getPreviousPeriodStart(currentStart: Date, period: SnapshotPeriod): Date {
|
||||
const start = new Date(currentStart);
|
||||
switch (period) {
|
||||
case 'daily':
|
||||
start.setDate(start.getDate() - 1);
|
||||
break;
|
||||
case 'weekly':
|
||||
start.setDate(start.getDate() - 7);
|
||||
break;
|
||||
case 'monthly':
|
||||
start.setMonth(start.getMonth() - 1);
|
||||
break;
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
private async calculateEngagementScore(entityId: string, since: Date): Promise<number> {
|
||||
// Base engagement from sponsor interactions
|
||||
const sponsorClicks = await this.engagementRepository.getSponsorClicksForEntity(entityId, since);
|
||||
return sponsorClicks * 10; // Weighted score
|
||||
}
|
||||
|
||||
private determineTrustIndicator(
|
||||
pageViews: number,
|
||||
uniqueVisitors: number,
|
||||
engagementScore: number
|
||||
): 'high' | 'medium' | 'low' {
|
||||
const engagementRate = pageViews > 0 ? engagementScore / pageViews : 0;
|
||||
const returningVisitorRate = pageViews > 0 ? (pageViews - uniqueVisitors) / pageViews : 0;
|
||||
|
||||
if (engagementRate > 0.1 && returningVisitorRate > 0.3) {
|
||||
return 'high';
|
||||
}
|
||||
if (engagementRate > 0.05 || returningVisitorRate > 0.1) {
|
||||
return 'medium';
|
||||
}
|
||||
return 'low';
|
||||
}
|
||||
|
||||
private calculateExposureValue(
|
||||
pageViews: number,
|
||||
uniqueVisitors: number,
|
||||
sponsorClicks: number
|
||||
): number {
|
||||
// Simple exposure value calculation (could be monetized)
|
||||
return (pageViews * 0.01) + (uniqueVisitors * 0.05) + (sponsorClicks * 0.50);
|
||||
}
|
||||
|
||||
private calculatePercentageChange(previous: number, current: number): number {
|
||||
if (previous === 0) return current > 0 ? 100 : 0;
|
||||
return Math.round(((current - previous) / previous) * 100);
|
||||
}
|
||||
|
||||
private formatPeriodLabel(start: Date, end: Date): string {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
return `${formatter.format(start)} - ${formatter.format(end)}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Use Case: RecordEngagementUseCase
|
||||
*
|
||||
* Records an engagement event when a visitor interacts with an entity.
|
||||
*/
|
||||
|
||||
import { EngagementEvent, type EngagementAction, type EngagementEntityType } from '../../domain/entities/EngagementEvent';
|
||||
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
|
||||
|
||||
export interface RecordEngagementInput {
|
||||
action: EngagementAction;
|
||||
entityType: EngagementEntityType;
|
||||
entityId: string;
|
||||
actorId?: string;
|
||||
actorType: 'anonymous' | 'driver' | 'sponsor';
|
||||
sessionId: string;
|
||||
metadata?: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
export interface RecordEngagementOutput {
|
||||
eventId: string;
|
||||
engagementWeight: number;
|
||||
}
|
||||
|
||||
export class RecordEngagementUseCase {
|
||||
constructor(private readonly engagementRepository: IEngagementRepository) {}
|
||||
|
||||
async execute(input: RecordEngagementInput): Promise<RecordEngagementOutput> {
|
||||
const eventId = `eng-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const event = EngagementEvent.create({
|
||||
id: eventId,
|
||||
action: input.action,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
actorId: input.actorId,
|
||||
actorType: input.actorType,
|
||||
sessionId: input.sessionId,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
|
||||
await this.engagementRepository.save(event);
|
||||
|
||||
return {
|
||||
eventId,
|
||||
engagementWeight: event.getEngagementWeight(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Use Case: RecordPageViewUseCase
|
||||
*
|
||||
* Records a page view event when a visitor accesses an entity page.
|
||||
*/
|
||||
|
||||
import { PageView, type EntityType, type VisitorType } from '../../domain/entities/PageView';
|
||||
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
|
||||
|
||||
export interface RecordPageViewInput {
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
visitorId?: string;
|
||||
visitorType: VisitorType;
|
||||
sessionId: string;
|
||||
referrer?: string;
|
||||
userAgent?: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
export interface RecordPageViewOutput {
|
||||
pageViewId: string;
|
||||
}
|
||||
|
||||
export class RecordPageViewUseCase {
|
||||
constructor(private readonly pageViewRepository: IPageViewRepository) {}
|
||||
|
||||
async execute(input: RecordPageViewInput): Promise<RecordPageViewOutput> {
|
||||
const pageViewId = `pv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const pageView = PageView.create({
|
||||
id: pageViewId,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
visitorId: input.visitorId,
|
||||
visitorType: input.visitorType,
|
||||
sessionId: input.sessionId,
|
||||
referrer: input.referrer,
|
||||
userAgent: input.userAgent,
|
||||
country: input.country,
|
||||
});
|
||||
|
||||
await this.pageViewRepository.save(pageView);
|
||||
|
||||
return { pageViewId };
|
||||
}
|
||||
}
|
||||
162
packages/analytics/domain/entities/AnalyticsSnapshot.ts
Normal file
162
packages/analytics/domain/entities/AnalyticsSnapshot.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Domain Entity: AnalyticsSnapshot
|
||||
*
|
||||
* Aggregated analytics data for a specific entity over a time period.
|
||||
* Pre-calculated metrics for sponsor dashboard and entity analytics.
|
||||
*/
|
||||
|
||||
export type SnapshotPeriod = 'daily' | 'weekly' | 'monthly';
|
||||
export type SnapshotEntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor';
|
||||
|
||||
export interface AnalyticsMetrics {
|
||||
pageViews: number;
|
||||
uniqueVisitors: number;
|
||||
avgSessionDuration: number;
|
||||
bounceRate: number;
|
||||
engagementScore: number;
|
||||
sponsorClicks: number;
|
||||
sponsorUrlClicks: number;
|
||||
socialShares: number;
|
||||
leagueJoins: number;
|
||||
raceRegistrations: number;
|
||||
exposureValue: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsSnapshotProps {
|
||||
id: string;
|
||||
entityType: SnapshotEntityType;
|
||||
entityId: string;
|
||||
period: SnapshotPeriod;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
metrics: AnalyticsMetrics;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class AnalyticsSnapshot {
|
||||
readonly id: string;
|
||||
readonly entityType: SnapshotEntityType;
|
||||
readonly entityId: string;
|
||||
readonly period: SnapshotPeriod;
|
||||
readonly startDate: Date;
|
||||
readonly endDate: Date;
|
||||
readonly metrics: AnalyticsMetrics;
|
||||
readonly createdAt: Date;
|
||||
|
||||
private constructor(props: AnalyticsSnapshotProps) {
|
||||
this.id = props.id;
|
||||
this.entityType = props.entityType;
|
||||
this.entityId = props.entityId;
|
||||
this.period = props.period;
|
||||
this.startDate = props.startDate;
|
||||
this.endDate = props.endDate;
|
||||
this.metrics = props.metrics;
|
||||
this.createdAt = props.createdAt;
|
||||
}
|
||||
|
||||
static create(props: Omit<AnalyticsSnapshotProps, 'createdAt'> & { createdAt?: Date }): AnalyticsSnapshot {
|
||||
this.validate(props);
|
||||
|
||||
return new AnalyticsSnapshot({
|
||||
...props,
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
static createEmpty(
|
||||
id: string,
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): AnalyticsSnapshot {
|
||||
return new AnalyticsSnapshot({
|
||||
id,
|
||||
entityType,
|
||||
entityId,
|
||||
period,
|
||||
startDate,
|
||||
endDate,
|
||||
metrics: {
|
||||
pageViews: 0,
|
||||
uniqueVisitors: 0,
|
||||
avgSessionDuration: 0,
|
||||
bounceRate: 0,
|
||||
engagementScore: 0,
|
||||
sponsorClicks: 0,
|
||||
sponsorUrlClicks: 0,
|
||||
socialShares: 0,
|
||||
leagueJoins: 0,
|
||||
raceRegistrations: 0,
|
||||
exposureValue: 0,
|
||||
},
|
||||
createdAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
private static validate(props: Omit<AnalyticsSnapshotProps, 'createdAt'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('AnalyticsSnapshot ID is required');
|
||||
}
|
||||
|
||||
if (!props.entityType) {
|
||||
throw new Error('AnalyticsSnapshot entityType is required');
|
||||
}
|
||||
|
||||
if (!props.entityId || props.entityId.trim().length === 0) {
|
||||
throw new Error('AnalyticsSnapshot entityId is required');
|
||||
}
|
||||
|
||||
if (!props.period) {
|
||||
throw new Error('AnalyticsSnapshot period is required');
|
||||
}
|
||||
|
||||
if (props.endDate < props.startDate) {
|
||||
throw new Error('AnalyticsSnapshot endDate must be after startDate');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate exposure score for sponsors (weighted combination of metrics)
|
||||
*/
|
||||
calculateExposureScore(): number {
|
||||
const { pageViews, uniqueVisitors, sponsorClicks, sponsorUrlClicks, socialShares } = this.metrics;
|
||||
|
||||
return (
|
||||
pageViews * 1 +
|
||||
uniqueVisitors * 2 +
|
||||
sponsorClicks * 10 +
|
||||
sponsorUrlClicks * 25 +
|
||||
socialShares * 5
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate trust indicator based on engagement quality
|
||||
*/
|
||||
getTrustIndicator(): 'high' | 'medium' | 'low' {
|
||||
const { bounceRate, avgSessionDuration, engagementScore } = this.metrics;
|
||||
|
||||
if (bounceRate < 30 && avgSessionDuration > 120000 && engagementScore > 50) {
|
||||
return 'high';
|
||||
}
|
||||
if (bounceRate < 60 && avgSessionDuration > 30000 && engagementScore > 20) {
|
||||
return 'medium';
|
||||
}
|
||||
return 'low';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get period comparison label
|
||||
*/
|
||||
getPeriodLabel(): string {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: this.period === 'monthly' ? 'numeric' : undefined
|
||||
});
|
||||
|
||||
return `${formatter.format(this.startDate)} - ${formatter.format(this.endDate)}`;
|
||||
}
|
||||
}
|
||||
121
packages/analytics/domain/entities/EngagementEvent.ts
Normal file
121
packages/analytics/domain/entities/EngagementEvent.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Domain Entity: EngagementEvent
|
||||
*
|
||||
* Represents user interactions beyond page views.
|
||||
* Tracks clicks, downloads, sign-ups, and other engagement actions.
|
||||
*/
|
||||
|
||||
export type EngagementAction =
|
||||
| 'click_sponsor_logo'
|
||||
| 'click_sponsor_url'
|
||||
| 'download_livery_pack'
|
||||
| 'join_league'
|
||||
| 'register_race'
|
||||
| 'view_standings'
|
||||
| 'view_schedule'
|
||||
| 'share_social'
|
||||
| 'contact_sponsor';
|
||||
|
||||
export type EngagementEntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor' | 'sponsorship';
|
||||
|
||||
export interface EngagementEventProps {
|
||||
id: string;
|
||||
action: EngagementAction;
|
||||
entityType: EngagementEntityType;
|
||||
entityId: string;
|
||||
actorId?: string;
|
||||
actorType: 'anonymous' | 'driver' | 'sponsor';
|
||||
sessionId: string;
|
||||
metadata?: Record<string, string | number | boolean>;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export class EngagementEvent {
|
||||
readonly id: string;
|
||||
readonly action: EngagementAction;
|
||||
readonly entityType: EngagementEntityType;
|
||||
readonly entityId: string;
|
||||
readonly actorId?: string;
|
||||
readonly actorType: 'anonymous' | 'driver' | 'sponsor';
|
||||
readonly sessionId: string;
|
||||
readonly metadata?: Record<string, string | number | boolean>;
|
||||
readonly timestamp: Date;
|
||||
|
||||
private constructor(props: EngagementEventProps) {
|
||||
this.id = props.id;
|
||||
this.action = props.action;
|
||||
this.entityType = props.entityType;
|
||||
this.entityId = props.entityId;
|
||||
this.actorId = props.actorId;
|
||||
this.actorType = props.actorType;
|
||||
this.sessionId = props.sessionId;
|
||||
this.metadata = props.metadata;
|
||||
this.timestamp = props.timestamp;
|
||||
}
|
||||
|
||||
static create(props: Omit<EngagementEventProps, 'timestamp'> & { timestamp?: Date }): EngagementEvent {
|
||||
this.validate(props);
|
||||
|
||||
return new EngagementEvent({
|
||||
...props,
|
||||
timestamp: props.timestamp ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
private static validate(props: Omit<EngagementEventProps, 'timestamp'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('EngagementEvent ID is required');
|
||||
}
|
||||
|
||||
if (!props.action) {
|
||||
throw new Error('EngagementEvent action is required');
|
||||
}
|
||||
|
||||
if (!props.entityType) {
|
||||
throw new Error('EngagementEvent entityType is required');
|
||||
}
|
||||
|
||||
if (!props.entityId || props.entityId.trim().length === 0) {
|
||||
throw new Error('EngagementEvent entityId is required');
|
||||
}
|
||||
|
||||
if (!props.sessionId || props.sessionId.trim().length === 0) {
|
||||
throw new Error('EngagementEvent sessionId is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a sponsor-related engagement
|
||||
*/
|
||||
isSponsorEngagement(): boolean {
|
||||
return this.action.startsWith('click_sponsor') ||
|
||||
this.action === 'contact_sponsor' ||
|
||||
this.entityType === 'sponsor' ||
|
||||
this.entityType === 'sponsorship';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a conversion event (high-value action)
|
||||
*/
|
||||
isConversionEvent(): boolean {
|
||||
return ['join_league', 'register_race', 'click_sponsor_url', 'contact_sponsor'].includes(this.action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engagement weight for analytics calculations
|
||||
*/
|
||||
getEngagementWeight(): number {
|
||||
const weights: Record<EngagementAction, number> = {
|
||||
'click_sponsor_logo': 2,
|
||||
'click_sponsor_url': 5,
|
||||
'download_livery_pack': 3,
|
||||
'join_league': 10,
|
||||
'register_race': 8,
|
||||
'view_standings': 1,
|
||||
'view_schedule': 1,
|
||||
'share_social': 4,
|
||||
'contact_sponsor': 15,
|
||||
};
|
||||
return weights[this.action] || 1;
|
||||
}
|
||||
}
|
||||
107
packages/analytics/domain/entities/PageView.ts
Normal file
107
packages/analytics/domain/entities/PageView.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Domain Entity: PageView
|
||||
*
|
||||
* Represents a single page view event for analytics tracking.
|
||||
* Captures visitor interactions with leagues, drivers, teams, races.
|
||||
*/
|
||||
|
||||
export type EntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor';
|
||||
export type VisitorType = 'anonymous' | 'driver' | 'sponsor';
|
||||
|
||||
export interface PageViewProps {
|
||||
id: string;
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
visitorId?: string;
|
||||
visitorType: VisitorType;
|
||||
sessionId: string;
|
||||
referrer?: string;
|
||||
userAgent?: string;
|
||||
country?: string;
|
||||
timestamp: Date;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export class PageView {
|
||||
readonly id: string;
|
||||
readonly entityType: EntityType;
|
||||
readonly entityId: string;
|
||||
readonly visitorId?: string;
|
||||
readonly visitorType: VisitorType;
|
||||
readonly sessionId: string;
|
||||
readonly referrer?: string;
|
||||
readonly userAgent?: string;
|
||||
readonly country?: string;
|
||||
readonly timestamp: Date;
|
||||
readonly durationMs?: number;
|
||||
|
||||
private constructor(props: PageViewProps) {
|
||||
this.id = props.id;
|
||||
this.entityType = props.entityType;
|
||||
this.entityId = props.entityId;
|
||||
this.visitorId = props.visitorId;
|
||||
this.visitorType = props.visitorType;
|
||||
this.sessionId = props.sessionId;
|
||||
this.referrer = props.referrer;
|
||||
this.userAgent = props.userAgent;
|
||||
this.country = props.country;
|
||||
this.timestamp = props.timestamp;
|
||||
this.durationMs = props.durationMs;
|
||||
}
|
||||
|
||||
static create(props: Omit<PageViewProps, 'timestamp'> & { timestamp?: Date }): PageView {
|
||||
this.validate(props);
|
||||
|
||||
return new PageView({
|
||||
...props,
|
||||
timestamp: props.timestamp ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
private static validate(props: Omit<PageViewProps, 'timestamp'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('PageView ID is required');
|
||||
}
|
||||
|
||||
if (!props.entityType) {
|
||||
throw new Error('PageView entityType is required');
|
||||
}
|
||||
|
||||
if (!props.entityId || props.entityId.trim().length === 0) {
|
||||
throw new Error('PageView entityId is required');
|
||||
}
|
||||
|
||||
if (!props.sessionId || props.sessionId.trim().length === 0) {
|
||||
throw new Error('PageView sessionId is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update duration when visitor leaves page
|
||||
*/
|
||||
withDuration(durationMs: number): PageView {
|
||||
if (durationMs < 0) {
|
||||
throw new Error('Duration must be non-negative');
|
||||
}
|
||||
|
||||
return new PageView({
|
||||
...this,
|
||||
durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a meaningful view (not a bounce)
|
||||
*/
|
||||
isMeaningfulView(): boolean {
|
||||
return this.durationMs !== undefined && this.durationMs >= 5000; // 5+ seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if view came from external source
|
||||
*/
|
||||
isExternalReferral(): boolean {
|
||||
if (!this.referrer) return false;
|
||||
return !this.referrer.includes('gridpilot');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Repository Interface: IAnalyticsSnapshotRepository
|
||||
*
|
||||
* Defines persistence operations for AnalyticsSnapshot entities.
|
||||
*/
|
||||
|
||||
import type { AnalyticsSnapshot, SnapshotPeriod, SnapshotEntityType } from '../entities/AnalyticsSnapshot';
|
||||
|
||||
export interface IAnalyticsSnapshotRepository {
|
||||
save(snapshot: AnalyticsSnapshot): Promise<void>;
|
||||
findById(id: string): Promise<AnalyticsSnapshot | null>;
|
||||
findByEntity(entityType: SnapshotEntityType, entityId: string): Promise<AnalyticsSnapshot[]>;
|
||||
findByPeriod(
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<AnalyticsSnapshot | null>;
|
||||
findLatest(entityType: SnapshotEntityType, entityId: string, period: SnapshotPeriod): Promise<AnalyticsSnapshot | null>;
|
||||
getHistoricalSnapshots(
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod,
|
||||
limit: number
|
||||
): Promise<AnalyticsSnapshot[]>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Repository Interface: IEngagementRepository
|
||||
*
|
||||
* Defines persistence operations for EngagementEvent entities.
|
||||
*/
|
||||
|
||||
import type { EngagementEvent, EngagementAction, EngagementEntityType } from '../entities/EngagementEvent';
|
||||
|
||||
export interface IEngagementRepository {
|
||||
save(event: EngagementEvent): Promise<void>;
|
||||
findById(id: string): Promise<EngagementEvent | null>;
|
||||
findByEntityId(entityType: EngagementEntityType, entityId: string): Promise<EngagementEvent[]>;
|
||||
findByAction(action: EngagementAction): Promise<EngagementEvent[]>;
|
||||
findByDateRange(startDate: Date, endDate: Date): Promise<EngagementEvent[]>;
|
||||
countByAction(action: EngagementAction, entityId?: string, since?: Date): Promise<number>;
|
||||
getSponsorClicksForEntity(entityId: string, since?: Date): Promise<number>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Repository Interface: IPageViewRepository
|
||||
*
|
||||
* Defines persistence operations for PageView entities.
|
||||
*/
|
||||
|
||||
import type { PageView, EntityType } from '../entities/PageView';
|
||||
|
||||
export interface IPageViewRepository {
|
||||
save(pageView: PageView): Promise<void>;
|
||||
findById(id: string): Promise<PageView | null>;
|
||||
findByEntityId(entityType: EntityType, entityId: string): Promise<PageView[]>;
|
||||
findByDateRange(startDate: Date, endDate: Date): Promise<PageView[]>;
|
||||
findBySession(sessionId: string): Promise<PageView[]>;
|
||||
countByEntityId(entityType: EntityType, entityId: string, since?: Date): Promise<number>;
|
||||
countUniqueVisitors(entityType: EntityType, entityId: string, since?: Date): Promise<number>;
|
||||
}
|
||||
26
packages/analytics/index.ts
Normal file
26
packages/analytics/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @gridpilot/analytics
|
||||
*
|
||||
* Analytics bounded context - tracks page views, engagement events,
|
||||
* and generates analytics snapshots for sponsor exposure metrics.
|
||||
*/
|
||||
|
||||
// Domain entities
|
||||
export * from './domain/entities/PageView';
|
||||
export * from './domain/entities/EngagementEvent';
|
||||
export * from './domain/entities/AnalyticsSnapshot';
|
||||
|
||||
// Domain repositories
|
||||
export * from './domain/repositories/IPageViewRepository';
|
||||
export * from './domain/repositories/IEngagementRepository';
|
||||
export * from './domain/repositories/IAnalyticsSnapshotRepository';
|
||||
|
||||
// Application use cases
|
||||
export * from './application/use-cases/RecordPageViewUseCase';
|
||||
export * from './application/use-cases/RecordEngagementUseCase';
|
||||
export * from './application/use-cases/GetEntityAnalyticsQuery';
|
||||
|
||||
// Infrastructure
|
||||
export * from './infrastructure/repositories/InMemoryPageViewRepository';
|
||||
export * from './infrastructure/repositories/InMemoryEngagementRepository';
|
||||
export * from './infrastructure/repositories/InMemoryAnalyticsSnapshotRepository';
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Infrastructure: InMemoryAnalyticsSnapshotRepository
|
||||
*
|
||||
* In-memory implementation of IAnalyticsSnapshotRepository for development/testing.
|
||||
*/
|
||||
|
||||
import type { IAnalyticsSnapshotRepository } from '../../domain/repositories/IAnalyticsSnapshotRepository';
|
||||
import { AnalyticsSnapshot, type SnapshotPeriod, type SnapshotEntityType } from '../../domain/entities/AnalyticsSnapshot';
|
||||
|
||||
export class InMemoryAnalyticsSnapshotRepository implements IAnalyticsSnapshotRepository {
|
||||
private snapshots: Map<string, AnalyticsSnapshot> = new Map();
|
||||
|
||||
async save(snapshot: AnalyticsSnapshot): Promise<void> {
|
||||
this.snapshots.set(snapshot.id, snapshot);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<AnalyticsSnapshot | null> {
|
||||
return this.snapshots.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findByEntity(entityType: SnapshotEntityType, entityId: string): Promise<AnalyticsSnapshot[]> {
|
||||
return Array.from(this.snapshots.values()).filter(
|
||||
s => s.entityType === entityType && s.entityId === entityId
|
||||
);
|
||||
}
|
||||
|
||||
async findByPeriod(
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<AnalyticsSnapshot | null> {
|
||||
return Array.from(this.snapshots.values()).find(
|
||||
s => s.entityType === entityType &&
|
||||
s.entityId === entityId &&
|
||||
s.period === period &&
|
||||
s.startDate >= startDate &&
|
||||
s.endDate <= endDate
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
async findLatest(
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod
|
||||
): Promise<AnalyticsSnapshot | null> {
|
||||
const matching = Array.from(this.snapshots.values())
|
||||
.filter(s => s.entityType === entityType && s.entityId === entityId && s.period === period)
|
||||
.sort((a, b) => b.endDate.getTime() - a.endDate.getTime());
|
||||
|
||||
return matching[0] ?? null;
|
||||
}
|
||||
|
||||
async getHistoricalSnapshots(
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod,
|
||||
limit: number
|
||||
): Promise<AnalyticsSnapshot[]> {
|
||||
return Array.from(this.snapshots.values())
|
||||
.filter(s => s.entityType === entityType && s.entityId === entityId && s.period === period)
|
||||
.sort((a, b) => b.endDate.getTime() - a.endDate.getTime())
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
// Helper for testing
|
||||
clear(): void {
|
||||
this.snapshots.clear();
|
||||
}
|
||||
|
||||
// Helper for seeding demo data
|
||||
seed(snapshots: AnalyticsSnapshot[]): void {
|
||||
snapshots.forEach(s => this.snapshots.set(s.id, s));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Infrastructure: InMemoryEngagementRepository
|
||||
*
|
||||
* In-memory implementation of IEngagementRepository for development/testing.
|
||||
*/
|
||||
|
||||
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
|
||||
import { EngagementEvent, type EngagementAction, type EngagementEntityType } from '../../domain/entities/EngagementEvent';
|
||||
|
||||
export class InMemoryEngagementRepository implements IEngagementRepository {
|
||||
private events: Map<string, EngagementEvent> = new Map();
|
||||
|
||||
async save(event: EngagementEvent): Promise<void> {
|
||||
this.events.set(event.id, event);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<EngagementEvent | null> {
|
||||
return this.events.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findByEntityId(entityType: EngagementEntityType, entityId: string): Promise<EngagementEvent[]> {
|
||||
return Array.from(this.events.values()).filter(
|
||||
e => e.entityType === entityType && e.entityId === entityId
|
||||
);
|
||||
}
|
||||
|
||||
async findByAction(action: EngagementAction): Promise<EngagementEvent[]> {
|
||||
return Array.from(this.events.values()).filter(
|
||||
e => e.action === action
|
||||
);
|
||||
}
|
||||
|
||||
async findByDateRange(startDate: Date, endDate: Date): Promise<EngagementEvent[]> {
|
||||
return Array.from(this.events.values()).filter(
|
||||
e => e.timestamp >= startDate && e.timestamp <= endDate
|
||||
);
|
||||
}
|
||||
|
||||
async countByAction(action: EngagementAction, entityId?: string, since?: Date): Promise<number> {
|
||||
return Array.from(this.events.values()).filter(
|
||||
e => e.action === action &&
|
||||
(!entityId || e.entityId === entityId) &&
|
||||
(!since || e.timestamp >= since)
|
||||
).length;
|
||||
}
|
||||
|
||||
async getSponsorClicksForEntity(entityId: string, since?: Date): Promise<number> {
|
||||
return Array.from(this.events.values()).filter(
|
||||
e => e.entityId === entityId &&
|
||||
(e.action === 'click_sponsor_logo' || e.action === 'click_sponsor_url') &&
|
||||
(!since || e.timestamp >= since)
|
||||
).length;
|
||||
}
|
||||
|
||||
// Helper for testing
|
||||
clear(): void {
|
||||
this.events.clear();
|
||||
}
|
||||
|
||||
// Helper for seeding demo data
|
||||
seed(events: EngagementEvent[]): void {
|
||||
events.forEach(e => this.events.set(e.id, e));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Infrastructure: InMemoryPageViewRepository
|
||||
*
|
||||
* In-memory implementation of IPageViewRepository for development/testing.
|
||||
*/
|
||||
|
||||
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
|
||||
import { PageView, type EntityType } from '../../domain/entities/PageView';
|
||||
|
||||
export class InMemoryPageViewRepository implements IPageViewRepository {
|
||||
private pageViews: Map<string, PageView> = new Map();
|
||||
|
||||
async save(pageView: PageView): Promise<void> {
|
||||
this.pageViews.set(pageView.id, pageView);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<PageView | null> {
|
||||
return this.pageViews.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findByEntityId(entityType: EntityType, entityId: string): Promise<PageView[]> {
|
||||
return Array.from(this.pageViews.values()).filter(
|
||||
pv => pv.entityType === entityType && pv.entityId === entityId
|
||||
);
|
||||
}
|
||||
|
||||
async findByDateRange(startDate: Date, endDate: Date): Promise<PageView[]> {
|
||||
return Array.from(this.pageViews.values()).filter(
|
||||
pv => pv.timestamp >= startDate && pv.timestamp <= endDate
|
||||
);
|
||||
}
|
||||
|
||||
async findBySession(sessionId: string): Promise<PageView[]> {
|
||||
return Array.from(this.pageViews.values()).filter(
|
||||
pv => pv.sessionId === sessionId
|
||||
);
|
||||
}
|
||||
|
||||
async countByEntityId(entityType: EntityType, entityId: string, since?: Date): Promise<number> {
|
||||
return Array.from(this.pageViews.values()).filter(
|
||||
pv => pv.entityType === entityType &&
|
||||
pv.entityId === entityId &&
|
||||
(!since || pv.timestamp >= since)
|
||||
).length;
|
||||
}
|
||||
|
||||
async countUniqueVisitors(entityType: EntityType, entityId: string, since?: Date): Promise<number> {
|
||||
const visitors = new Set<string>();
|
||||
Array.from(this.pageViews.values())
|
||||
.filter(
|
||||
pv => pv.entityType === entityType &&
|
||||
pv.entityId === entityId &&
|
||||
(!since || pv.timestamp >= since)
|
||||
)
|
||||
.forEach(pv => {
|
||||
visitors.add(pv.visitorId ?? pv.sessionId);
|
||||
});
|
||||
return visitors.size;
|
||||
}
|
||||
|
||||
// Helper for testing
|
||||
clear(): void {
|
||||
this.pageViews.clear();
|
||||
}
|
||||
|
||||
// Helper for seeding demo data
|
||||
seed(pageViews: PageView[]): void {
|
||||
pageViews.forEach(pv => this.pageViews.set(pv.id, pv));
|
||||
}
|
||||
}
|
||||
14
packages/analytics/package.json
Normal file
14
packages/analytics/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@gridpilot/analytics",
|
||||
"version": "0.1.0",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./domain/*": "./domain/*",
|
||||
"./application/*": "./application/*",
|
||||
"./infrastructure/*": "./infrastructure/*"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
390
packages/identity/domain/entities/Achievement.ts
Normal file
390
packages/identity/domain/entities/Achievement.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Domain Entity: Achievement
|
||||
*
|
||||
* Represents an achievement that can be earned by users.
|
||||
* Achievements are categorized by role (driver, steward, admin) and type.
|
||||
*/
|
||||
|
||||
export type AchievementCategory = 'driver' | 'steward' | 'admin' | 'community';
|
||||
|
||||
export type AchievementRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||
|
||||
export interface AchievementProps {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: AchievementCategory;
|
||||
rarity: AchievementRarity;
|
||||
iconUrl?: string;
|
||||
points: number;
|
||||
requirements: AchievementRequirement[];
|
||||
isSecret: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface AchievementRequirement {
|
||||
type: 'races_completed' | 'wins' | 'podiums' | 'clean_races' | 'protests_handled' |
|
||||
'leagues_managed' | 'seasons_completed' | 'consecutive_clean' | 'rating_threshold' |
|
||||
'trust_threshold' | 'events_stewarded' | 'members_managed' | 'championships_won';
|
||||
value: number;
|
||||
operator: '>=' | '>' | '=' | '<' | '<=';
|
||||
}
|
||||
|
||||
export class Achievement {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly category: AchievementCategory;
|
||||
readonly rarity: AchievementRarity;
|
||||
readonly iconUrl?: string;
|
||||
readonly points: number;
|
||||
readonly requirements: AchievementRequirement[];
|
||||
readonly isSecret: boolean;
|
||||
readonly createdAt: Date;
|
||||
|
||||
private constructor(props: AchievementProps) {
|
||||
this.id = props.id;
|
||||
this.name = props.name;
|
||||
this.description = props.description;
|
||||
this.category = props.category;
|
||||
this.rarity = props.rarity;
|
||||
this.iconUrl = props.iconUrl;
|
||||
this.points = props.points;
|
||||
this.requirements = props.requirements;
|
||||
this.isSecret = props.isSecret;
|
||||
this.createdAt = props.createdAt;
|
||||
}
|
||||
|
||||
static create(props: Omit<AchievementProps, 'createdAt'> & { createdAt?: Date }): Achievement {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Achievement ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new Error('Achievement name is required');
|
||||
}
|
||||
|
||||
if (props.requirements.length === 0) {
|
||||
throw new Error('Achievement must have at least one requirement');
|
||||
}
|
||||
|
||||
return new Achievement({
|
||||
...props,
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user stats meet all requirements
|
||||
*/
|
||||
checkRequirements(stats: Record<string, number>): boolean {
|
||||
return this.requirements.every(req => {
|
||||
const value = stats[req.type] ?? 0;
|
||||
switch (req.operator) {
|
||||
case '>=': return value >= req.value;
|
||||
case '>': return value > req.value;
|
||||
case '=': return value === req.value;
|
||||
case '<': return value < req.value;
|
||||
case '<=': return value <= req.value;
|
||||
default: return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rarity color for display
|
||||
*/
|
||||
getRarityColor(): string {
|
||||
const colors: Record<AchievementRarity, string> = {
|
||||
common: '#9CA3AF',
|
||||
uncommon: '#22C55E',
|
||||
rare: '#3B82F6',
|
||||
epic: '#A855F7',
|
||||
legendary: '#F59E0B',
|
||||
};
|
||||
return colors[this.rarity];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display text for hidden achievements
|
||||
*/
|
||||
getDisplayName(): string {
|
||||
if (this.isSecret) {
|
||||
return '???';
|
||||
}
|
||||
return this.name;
|
||||
}
|
||||
|
||||
getDisplayDescription(): string {
|
||||
if (this.isSecret) {
|
||||
return 'This achievement is secret. Keep playing to unlock it!';
|
||||
}
|
||||
return this.description;
|
||||
}
|
||||
}
|
||||
|
||||
// Predefined achievements for drivers
|
||||
export const DRIVER_ACHIEVEMENTS: Omit<AchievementProps, 'createdAt'>[] = [
|
||||
{
|
||||
id: 'first-race',
|
||||
name: 'First Steps',
|
||||
description: 'Complete your first race',
|
||||
category: 'driver',
|
||||
rarity: 'common',
|
||||
points: 10,
|
||||
requirements: [{ type: 'races_completed', value: 1, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'ten-races',
|
||||
name: 'Getting Started',
|
||||
description: 'Complete 10 races',
|
||||
category: 'driver',
|
||||
rarity: 'common',
|
||||
points: 25,
|
||||
requirements: [{ type: 'races_completed', value: 10, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'fifty-races',
|
||||
name: 'Regular Racer',
|
||||
description: 'Complete 50 races',
|
||||
category: 'driver',
|
||||
rarity: 'uncommon',
|
||||
points: 50,
|
||||
requirements: [{ type: 'races_completed', value: 50, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'hundred-races',
|
||||
name: 'Veteran',
|
||||
description: 'Complete 100 races',
|
||||
category: 'driver',
|
||||
rarity: 'rare',
|
||||
points: 100,
|
||||
requirements: [{ type: 'races_completed', value: 100, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'first-win',
|
||||
name: 'Victory Lane',
|
||||
description: 'Win your first race',
|
||||
category: 'driver',
|
||||
rarity: 'uncommon',
|
||||
points: 50,
|
||||
requirements: [{ type: 'wins', value: 1, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'ten-wins',
|
||||
name: 'Serial Winner',
|
||||
description: 'Win 10 races',
|
||||
category: 'driver',
|
||||
rarity: 'rare',
|
||||
points: 100,
|
||||
requirements: [{ type: 'wins', value: 10, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'first-podium',
|
||||
name: 'Podium Finisher',
|
||||
description: 'Finish on the podium',
|
||||
category: 'driver',
|
||||
rarity: 'common',
|
||||
points: 25,
|
||||
requirements: [{ type: 'podiums', value: 1, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'clean-streak-5',
|
||||
name: 'Clean Racer',
|
||||
description: 'Complete 5 consecutive races without incidents',
|
||||
category: 'driver',
|
||||
rarity: 'uncommon',
|
||||
points: 50,
|
||||
requirements: [{ type: 'consecutive_clean', value: 5, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'clean-streak-10',
|
||||
name: 'Safety First',
|
||||
description: 'Complete 10 consecutive races without incidents',
|
||||
category: 'driver',
|
||||
rarity: 'rare',
|
||||
points: 100,
|
||||
requirements: [{ type: 'consecutive_clean', value: 10, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'championship-win',
|
||||
name: 'Champion',
|
||||
description: 'Win a championship',
|
||||
category: 'driver',
|
||||
rarity: 'epic',
|
||||
points: 200,
|
||||
requirements: [{ type: 'championships_won', value: 1, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'triple-crown',
|
||||
name: 'Triple Crown',
|
||||
description: 'Win 3 championships',
|
||||
category: 'driver',
|
||||
rarity: 'legendary',
|
||||
points: 500,
|
||||
requirements: [{ type: 'championships_won', value: 3, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'elite-driver',
|
||||
name: 'Elite Driver',
|
||||
description: 'Reach Elite driver rating',
|
||||
category: 'driver',
|
||||
rarity: 'epic',
|
||||
points: 250,
|
||||
requirements: [{ type: 'rating_threshold', value: 90, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Predefined achievements for stewards
|
||||
export const STEWARD_ACHIEVEMENTS: Omit<AchievementProps, 'createdAt'>[] = [
|
||||
{
|
||||
id: 'first-protest',
|
||||
name: 'Justice Served',
|
||||
description: 'Handle your first protest',
|
||||
category: 'steward',
|
||||
rarity: 'common',
|
||||
points: 15,
|
||||
requirements: [{ type: 'protests_handled', value: 1, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'ten-protests',
|
||||
name: 'Fair Judge',
|
||||
description: 'Handle 10 protests',
|
||||
category: 'steward',
|
||||
rarity: 'uncommon',
|
||||
points: 50,
|
||||
requirements: [{ type: 'protests_handled', value: 10, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'fifty-protests',
|
||||
name: 'Senior Steward',
|
||||
description: 'Handle 50 protests',
|
||||
category: 'steward',
|
||||
rarity: 'rare',
|
||||
points: 100,
|
||||
requirements: [{ type: 'protests_handled', value: 50, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'hundred-protests',
|
||||
name: 'Chief Steward',
|
||||
description: 'Handle 100 protests',
|
||||
category: 'steward',
|
||||
rarity: 'epic',
|
||||
points: 200,
|
||||
requirements: [{ type: 'protests_handled', value: 100, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'event-steward-10',
|
||||
name: 'Event Official',
|
||||
description: 'Steward 10 race events',
|
||||
category: 'steward',
|
||||
rarity: 'uncommon',
|
||||
points: 50,
|
||||
requirements: [{ type: 'events_stewarded', value: 10, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'trusted-steward',
|
||||
name: 'Trusted Steward',
|
||||
description: 'Achieve highly-trusted status',
|
||||
category: 'steward',
|
||||
rarity: 'rare',
|
||||
points: 150,
|
||||
requirements: [{ type: 'trust_threshold', value: 75, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Predefined achievements for admins
|
||||
export const ADMIN_ACHIEVEMENTS: Omit<AchievementProps, 'createdAt'>[] = [
|
||||
{
|
||||
id: 'first-league',
|
||||
name: 'League Founder',
|
||||
description: 'Create your first league',
|
||||
category: 'admin',
|
||||
rarity: 'common',
|
||||
points: 25,
|
||||
requirements: [{ type: 'leagues_managed', value: 1, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'first-season',
|
||||
name: 'Season Organizer',
|
||||
description: 'Complete your first full season',
|
||||
category: 'admin',
|
||||
rarity: 'uncommon',
|
||||
points: 50,
|
||||
requirements: [{ type: 'seasons_completed', value: 1, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'five-seasons',
|
||||
name: 'Experienced Organizer',
|
||||
description: 'Complete 5 seasons',
|
||||
category: 'admin',
|
||||
rarity: 'rare',
|
||||
points: 100,
|
||||
requirements: [{ type: 'seasons_completed', value: 5, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'ten-seasons',
|
||||
name: 'Veteran Organizer',
|
||||
description: 'Complete 10 seasons',
|
||||
category: 'admin',
|
||||
rarity: 'epic',
|
||||
points: 200,
|
||||
requirements: [{ type: 'seasons_completed', value: 10, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'large-league',
|
||||
name: 'Community Builder',
|
||||
description: 'Manage a league with 50+ members',
|
||||
category: 'admin',
|
||||
rarity: 'rare',
|
||||
points: 150,
|
||||
requirements: [{ type: 'members_managed', value: 50, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
id: 'huge-league',
|
||||
name: 'Empire Builder',
|
||||
description: 'Manage a league with 100+ members',
|
||||
category: 'admin',
|
||||
rarity: 'epic',
|
||||
points: 300,
|
||||
requirements: [{ type: 'members_managed', value: 100, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Community achievements (for all roles)
|
||||
export const COMMUNITY_ACHIEVEMENTS: Omit<AchievementProps, 'createdAt'>[] = [
|
||||
{
|
||||
id: 'community-leader',
|
||||
name: 'Community Leader',
|
||||
description: 'Achieve community leader trust level',
|
||||
category: 'community',
|
||||
rarity: 'legendary',
|
||||
points: 500,
|
||||
requirements: [{ type: 'trust_threshold', value: 90, operator: '>=' }],
|
||||
isSecret: false,
|
||||
},
|
||||
];
|
||||
151
packages/identity/domain/entities/SponsorAccount.ts
Normal file
151
packages/identity/domain/entities/SponsorAccount.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Domain Entity: SponsorAccount
|
||||
*
|
||||
* Represents a sponsor's login account in the identity bounded context.
|
||||
* Separate from the racing domain's Sponsor entity which holds business data.
|
||||
*/
|
||||
|
||||
import { UserId } from '../value-objects/UserId';
|
||||
import type { EmailValidationResult } from '../value-objects/EmailAddress';
|
||||
import { validateEmail } from '../value-objects/EmailAddress';
|
||||
|
||||
export interface SponsorAccountProps {
|
||||
id: UserId;
|
||||
sponsorId: string; // Reference to racing domain's Sponsor entity
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
companyName: string;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
lastLoginAt?: Date;
|
||||
}
|
||||
|
||||
export class SponsorAccount {
|
||||
private readonly id: UserId;
|
||||
private readonly sponsorId: string;
|
||||
private email: string;
|
||||
private passwordHash: string;
|
||||
private companyName: string;
|
||||
private isActive: boolean;
|
||||
private readonly createdAt: Date;
|
||||
private lastLoginAt?: Date;
|
||||
|
||||
private constructor(props: SponsorAccountProps) {
|
||||
this.id = props.id;
|
||||
this.sponsorId = props.sponsorId;
|
||||
this.email = props.email;
|
||||
this.passwordHash = props.passwordHash;
|
||||
this.companyName = props.companyName;
|
||||
this.isActive = props.isActive;
|
||||
this.createdAt = props.createdAt;
|
||||
this.lastLoginAt = props.lastLoginAt;
|
||||
}
|
||||
|
||||
public static create(props: Omit<SponsorAccountProps, 'createdAt' | 'isActive'> & {
|
||||
createdAt?: Date;
|
||||
isActive?: boolean;
|
||||
}): SponsorAccount {
|
||||
if (!props.sponsorId || !props.sponsorId.trim()) {
|
||||
throw new Error('SponsorAccount sponsorId is required');
|
||||
}
|
||||
|
||||
if (!props.companyName || !props.companyName.trim()) {
|
||||
throw new Error('SponsorAccount companyName is required');
|
||||
}
|
||||
|
||||
if (!props.passwordHash || !props.passwordHash.trim()) {
|
||||
throw new Error('SponsorAccount passwordHash is required');
|
||||
}
|
||||
|
||||
const emailResult: EmailValidationResult = validateEmail(props.email);
|
||||
if (!emailResult.success) {
|
||||
throw new Error(emailResult.error);
|
||||
}
|
||||
|
||||
return new SponsorAccount({
|
||||
...props,
|
||||
email: emailResult.email,
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
isActive: props.isActive ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
public getId(): UserId {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public getSponsorId(): string {
|
||||
return this.sponsorId;
|
||||
}
|
||||
|
||||
public getEmail(): string {
|
||||
return this.email;
|
||||
}
|
||||
|
||||
public getPasswordHash(): string {
|
||||
return this.passwordHash;
|
||||
}
|
||||
|
||||
public getCompanyName(): string {
|
||||
return this.companyName;
|
||||
}
|
||||
|
||||
public getIsActive(): boolean {
|
||||
return this.isActive;
|
||||
}
|
||||
|
||||
public getCreatedAt(): Date {
|
||||
return this.createdAt;
|
||||
}
|
||||
|
||||
public getLastLoginAt(): Date | undefined {
|
||||
return this.lastLoginAt;
|
||||
}
|
||||
|
||||
public canLogin(): boolean {
|
||||
return this.isActive;
|
||||
}
|
||||
|
||||
public recordLogin(): SponsorAccount {
|
||||
return new SponsorAccount({
|
||||
id: this.id,
|
||||
sponsorId: this.sponsorId,
|
||||
email: this.email,
|
||||
passwordHash: this.passwordHash,
|
||||
companyName: this.companyName,
|
||||
isActive: this.isActive,
|
||||
createdAt: this.createdAt,
|
||||
lastLoginAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
public deactivate(): SponsorAccount {
|
||||
return new SponsorAccount({
|
||||
id: this.id,
|
||||
sponsorId: this.sponsorId,
|
||||
email: this.email,
|
||||
passwordHash: this.passwordHash,
|
||||
companyName: this.companyName,
|
||||
isActive: false,
|
||||
createdAt: this.createdAt,
|
||||
lastLoginAt: this.lastLoginAt,
|
||||
});
|
||||
}
|
||||
|
||||
public updatePassword(newPasswordHash: string): SponsorAccount {
|
||||
if (!newPasswordHash || !newPasswordHash.trim()) {
|
||||
throw new Error('Password hash cannot be empty');
|
||||
}
|
||||
|
||||
return new SponsorAccount({
|
||||
id: this.id,
|
||||
sponsorId: this.sponsorId,
|
||||
email: this.email,
|
||||
passwordHash: newPasswordHash,
|
||||
companyName: this.companyName,
|
||||
isActive: this.isActive,
|
||||
createdAt: this.createdAt,
|
||||
lastLoginAt: this.lastLoginAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
83
packages/identity/domain/entities/UserAchievement.ts
Normal file
83
packages/identity/domain/entities/UserAchievement.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Domain Entity: UserAchievement
|
||||
*
|
||||
* Represents an achievement earned by a specific user.
|
||||
*/
|
||||
|
||||
export interface UserAchievementProps {
|
||||
id: string;
|
||||
userId: string;
|
||||
achievementId: string;
|
||||
earnedAt: Date;
|
||||
notifiedAt?: Date;
|
||||
progress?: number; // For partial progress tracking (0-100)
|
||||
}
|
||||
|
||||
export class UserAchievement {
|
||||
readonly id: string;
|
||||
readonly userId: string;
|
||||
readonly achievementId: string;
|
||||
readonly earnedAt: Date;
|
||||
readonly notifiedAt?: Date;
|
||||
readonly progress: number;
|
||||
|
||||
private constructor(props: UserAchievementProps) {
|
||||
this.id = props.id;
|
||||
this.userId = props.userId;
|
||||
this.achievementId = props.achievementId;
|
||||
this.earnedAt = props.earnedAt;
|
||||
this.notifiedAt = props.notifiedAt;
|
||||
this.progress = props.progress ?? 100;
|
||||
}
|
||||
|
||||
static create(props: UserAchievementProps): UserAchievement {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('UserAchievement ID is required');
|
||||
}
|
||||
|
||||
if (!props.userId || props.userId.trim().length === 0) {
|
||||
throw new Error('UserAchievement userId is required');
|
||||
}
|
||||
|
||||
if (!props.achievementId || props.achievementId.trim().length === 0) {
|
||||
throw new Error('UserAchievement achievementId is required');
|
||||
}
|
||||
|
||||
return new UserAchievement(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark achievement as notified to user
|
||||
*/
|
||||
markNotified(): UserAchievement {
|
||||
return new UserAchievement({
|
||||
...this,
|
||||
notifiedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress towards achievement
|
||||
*/
|
||||
updateProgress(progress: number): UserAchievement {
|
||||
const clampedProgress = Math.max(0, Math.min(100, progress));
|
||||
return new UserAchievement({
|
||||
...this,
|
||||
progress: clampedProgress,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if achievement is fully earned
|
||||
*/
|
||||
isComplete(): boolean {
|
||||
return this.progress >= 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has been notified
|
||||
*/
|
||||
isNotified(): boolean {
|
||||
return this.notifiedAt !== undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Repository Interface: IAchievementRepository
|
||||
*
|
||||
* Defines operations for Achievement and UserAchievement entities
|
||||
*/
|
||||
|
||||
import type { Achievement, AchievementCategory } from '../entities/Achievement';
|
||||
import type { UserAchievement } from '../entities/UserAchievement';
|
||||
|
||||
export interface IAchievementRepository {
|
||||
// Achievement operations
|
||||
findAchievementById(id: string): Promise<Achievement | null>;
|
||||
findAllAchievements(): Promise<Achievement[]>;
|
||||
findAchievementsByCategory(category: AchievementCategory): Promise<Achievement[]>;
|
||||
createAchievement(achievement: Achievement): Promise<Achievement>;
|
||||
|
||||
// UserAchievement operations
|
||||
findUserAchievementById(id: string): Promise<UserAchievement | null>;
|
||||
findUserAchievementsByUserId(userId: string): Promise<UserAchievement[]>;
|
||||
findUserAchievementByUserAndAchievement(userId: string, achievementId: string): Promise<UserAchievement | null>;
|
||||
hasUserEarnedAchievement(userId: string, achievementId: string): Promise<boolean>;
|
||||
createUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement>;
|
||||
updateUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement>;
|
||||
|
||||
// Stats
|
||||
getAchievementLeaderboard(limit: number): Promise<{ userId: string; points: number; count: number }[]>;
|
||||
getUserAchievementStats(userId: string): Promise<{ total: number; points: number; byCategory: Record<AchievementCategory, number> }>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Repository Interface: ISponsorAccountRepository
|
||||
*
|
||||
* Defines persistence operations for SponsorAccount entities.
|
||||
*/
|
||||
|
||||
import type { SponsorAccount } from '../entities/SponsorAccount';
|
||||
import type { UserId } from '../value-objects/UserId';
|
||||
|
||||
export interface ISponsorAccountRepository {
|
||||
save(account: SponsorAccount): Promise<void>;
|
||||
findById(id: UserId): Promise<SponsorAccount | null>;
|
||||
findBySponsorId(sponsorId: string): Promise<SponsorAccount | null>;
|
||||
findByEmail(email: string): Promise<SponsorAccount | null>;
|
||||
delete(id: UserId): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Repository Interface: IUserRatingRepository
|
||||
*
|
||||
* Defines operations for UserRating value objects
|
||||
*/
|
||||
|
||||
import type { UserRating } from '../value-objects/UserRating';
|
||||
|
||||
export interface IUserRatingRepository {
|
||||
/**
|
||||
* Find rating by user ID
|
||||
*/
|
||||
findByUserId(userId: string): Promise<UserRating | null>;
|
||||
|
||||
/**
|
||||
* Find ratings by multiple user IDs
|
||||
*/
|
||||
findByUserIds(userIds: string[]): Promise<UserRating[]>;
|
||||
|
||||
/**
|
||||
* Save or update a user rating
|
||||
*/
|
||||
save(rating: UserRating): Promise<UserRating>;
|
||||
|
||||
/**
|
||||
* Get top rated drivers
|
||||
*/
|
||||
getTopDrivers(limit: number): Promise<UserRating[]>;
|
||||
|
||||
/**
|
||||
* Get top trusted users
|
||||
*/
|
||||
getTopTrusted(limit: number): Promise<UserRating[]>;
|
||||
|
||||
/**
|
||||
* Get eligible stewards (based on trust and fairness thresholds)
|
||||
*/
|
||||
getEligibleStewards(): Promise<UserRating[]>;
|
||||
|
||||
/**
|
||||
* Get ratings by driver tier
|
||||
*/
|
||||
findByDriverTier(tier: 'rookie' | 'amateur' | 'semi-pro' | 'pro' | 'elite'): Promise<UserRating[]>;
|
||||
|
||||
/**
|
||||
* Delete rating by user ID
|
||||
*/
|
||||
delete(userId: string): Promise<void>;
|
||||
}
|
||||
255
packages/identity/domain/value-objects/UserRating.ts
Normal file
255
packages/identity/domain/value-objects/UserRating.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Value Object: UserRating
|
||||
*
|
||||
* Multi-dimensional rating system for users covering:
|
||||
* - Driver skill: racing ability, lap times, consistency
|
||||
* - Admin competence: league management, event organization
|
||||
* - Steward fairness: protest handling, penalty consistency
|
||||
* - Trust score: reliability, sportsmanship, rule compliance
|
||||
* - Fairness score: clean racing, incident involvement
|
||||
*/
|
||||
|
||||
export interface RatingDimension {
|
||||
value: number; // Current rating value (0-100 scale)
|
||||
confidence: number; // Confidence level based on sample size (0-1)
|
||||
sampleSize: number; // Number of events contributing to this rating
|
||||
trend: 'rising' | 'stable' | 'falling';
|
||||
lastUpdated: Date;
|
||||
}
|
||||
|
||||
export interface UserRatingProps {
|
||||
userId: string;
|
||||
driver: RatingDimension;
|
||||
admin: RatingDimension;
|
||||
steward: RatingDimension;
|
||||
trust: RatingDimension;
|
||||
fairness: RatingDimension;
|
||||
overallReputation: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const DEFAULT_DIMENSION: RatingDimension = {
|
||||
value: 50,
|
||||
confidence: 0,
|
||||
sampleSize: 0,
|
||||
trend: 'stable',
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
|
||||
export class UserRating {
|
||||
readonly userId: string;
|
||||
readonly driver: RatingDimension;
|
||||
readonly admin: RatingDimension;
|
||||
readonly steward: RatingDimension;
|
||||
readonly trust: RatingDimension;
|
||||
readonly fairness: RatingDimension;
|
||||
readonly overallReputation: number;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
|
||||
private constructor(props: UserRatingProps) {
|
||||
this.userId = props.userId;
|
||||
this.driver = props.driver;
|
||||
this.admin = props.admin;
|
||||
this.steward = props.steward;
|
||||
this.trust = props.trust;
|
||||
this.fairness = props.fairness;
|
||||
this.overallReputation = props.overallReputation;
|
||||
this.createdAt = props.createdAt;
|
||||
this.updatedAt = props.updatedAt;
|
||||
}
|
||||
|
||||
static create(userId: string): UserRating {
|
||||
if (!userId || userId.trim().length === 0) {
|
||||
throw new Error('UserRating userId is required');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
return new UserRating({
|
||||
userId,
|
||||
driver: { ...DEFAULT_DIMENSION, lastUpdated: now },
|
||||
admin: { ...DEFAULT_DIMENSION, lastUpdated: now },
|
||||
steward: { ...DEFAULT_DIMENSION, lastUpdated: now },
|
||||
trust: { ...DEFAULT_DIMENSION, lastUpdated: now },
|
||||
fairness: { ...DEFAULT_DIMENSION, lastUpdated: now },
|
||||
overallReputation: 50,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
static restore(props: UserRatingProps): UserRating {
|
||||
return new UserRating(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update driver rating based on race performance
|
||||
*/
|
||||
updateDriverRating(
|
||||
newValue: number,
|
||||
weight: number = 1
|
||||
): UserRating {
|
||||
const updated = this.updateDimension(this.driver, newValue, weight);
|
||||
return this.withUpdates({ driver: updated });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update admin rating based on league management feedback
|
||||
*/
|
||||
updateAdminRating(
|
||||
newValue: number,
|
||||
weight: number = 1
|
||||
): UserRating {
|
||||
const updated = this.updateDimension(this.admin, newValue, weight);
|
||||
return this.withUpdates({ admin: updated });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update steward rating based on protest handling feedback
|
||||
*/
|
||||
updateStewardRating(
|
||||
newValue: number,
|
||||
weight: number = 1
|
||||
): UserRating {
|
||||
const updated = this.updateDimension(this.steward, newValue, weight);
|
||||
return this.withUpdates({ steward: updated });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update trust score based on reliability and sportsmanship
|
||||
*/
|
||||
updateTrustScore(
|
||||
newValue: number,
|
||||
weight: number = 1
|
||||
): UserRating {
|
||||
const updated = this.updateDimension(this.trust, newValue, weight);
|
||||
return this.withUpdates({ trust: updated });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update fairness score based on clean racing incidents
|
||||
*/
|
||||
updateFairnessScore(
|
||||
newValue: number,
|
||||
weight: number = 1
|
||||
): UserRating {
|
||||
const updated = this.updateDimension(this.fairness, newValue, weight);
|
||||
return this.withUpdates({ fairness: updated });
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate weighted overall reputation
|
||||
*/
|
||||
calculateOverallReputation(): number {
|
||||
// Weight dimensions by confidence and importance
|
||||
const weights = {
|
||||
driver: 0.25 * this.driver.confidence,
|
||||
admin: 0.15 * this.admin.confidence,
|
||||
steward: 0.15 * this.steward.confidence,
|
||||
trust: 0.25 * this.trust.confidence,
|
||||
fairness: 0.20 * this.fairness.confidence,
|
||||
};
|
||||
|
||||
const totalWeight = Object.values(weights).reduce((sum, w) => sum + w, 0);
|
||||
|
||||
if (totalWeight === 0) {
|
||||
return 50; // Default when no ratings yet
|
||||
}
|
||||
|
||||
const weightedSum =
|
||||
this.driver.value * weights.driver +
|
||||
this.admin.value * weights.admin +
|
||||
this.steward.value * weights.steward +
|
||||
this.trust.value * weights.trust +
|
||||
this.fairness.value * weights.fairness;
|
||||
|
||||
return Math.round(weightedSum / totalWeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rating tier for display
|
||||
*/
|
||||
getDriverTier(): 'rookie' | 'amateur' | 'semi-pro' | 'pro' | 'elite' {
|
||||
if (this.driver.value >= 90) return 'elite';
|
||||
if (this.driver.value >= 75) return 'pro';
|
||||
if (this.driver.value >= 60) return 'semi-pro';
|
||||
if (this.driver.value >= 40) return 'amateur';
|
||||
return 'rookie';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trust level for matchmaking
|
||||
*/
|
||||
getTrustLevel(): 'unverified' | 'trusted' | 'highly-trusted' | 'community-leader' {
|
||||
if (this.trust.value >= 90 && this.trust.sampleSize >= 50) return 'community-leader';
|
||||
if (this.trust.value >= 75 && this.trust.sampleSize >= 20) return 'highly-trusted';
|
||||
if (this.trust.value >= 60 && this.trust.sampleSize >= 5) return 'trusted';
|
||||
return 'unverified';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is eligible to be a steward
|
||||
*/
|
||||
canBeSteward(): boolean {
|
||||
return (
|
||||
this.trust.value >= 70 &&
|
||||
this.fairness.value >= 70 &&
|
||||
this.trust.sampleSize >= 10
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is eligible to be an admin
|
||||
*/
|
||||
canBeAdmin(): boolean {
|
||||
return (
|
||||
this.trust.value >= 60 &&
|
||||
this.trust.sampleSize >= 5
|
||||
);
|
||||
}
|
||||
|
||||
private updateDimension(
|
||||
dimension: RatingDimension,
|
||||
newValue: number,
|
||||
weight: number
|
||||
): RatingDimension {
|
||||
const clampedValue = Math.max(0, Math.min(100, newValue));
|
||||
const newSampleSize = dimension.sampleSize + weight;
|
||||
|
||||
// Exponential moving average with decay based on sample size
|
||||
const alpha = Math.min(0.3, 1 / (dimension.sampleSize + 1));
|
||||
const updatedValue = dimension.value * (1 - alpha) + clampedValue * alpha;
|
||||
|
||||
// Calculate confidence (asymptotic to 1)
|
||||
const confidence = 1 - Math.exp(-newSampleSize / 20);
|
||||
|
||||
// Determine trend
|
||||
const valueDiff = updatedValue - dimension.value;
|
||||
let trend: 'rising' | 'stable' | 'falling' = 'stable';
|
||||
if (valueDiff > 2) trend = 'rising';
|
||||
if (valueDiff < -2) trend = 'falling';
|
||||
|
||||
return {
|
||||
value: Math.round(updatedValue * 10) / 10,
|
||||
confidence: Math.round(confidence * 100) / 100,
|
||||
sampleSize: newSampleSize,
|
||||
trend,
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
private withUpdates(updates: Partial<UserRatingProps>): UserRating {
|
||||
const newRating = new UserRating({
|
||||
...this,
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
// Recalculate overall reputation
|
||||
return new UserRating({
|
||||
...newRating,
|
||||
overallReputation: newRating.calculateOverallReputation(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,18 @@
|
||||
export * from './domain/value-objects/EmailAddress';
|
||||
export * from './domain/value-objects/UserId';
|
||||
export * from './domain/value-objects/UserRating';
|
||||
export * from './domain/entities/User';
|
||||
export * from './domain/entities/SponsorAccount';
|
||||
export * from './domain/entities/Achievement';
|
||||
export * from './domain/entities/UserAchievement';
|
||||
|
||||
export * from './domain/repositories/IUserRepository';
|
||||
export * from './domain/repositories/ISponsorAccountRepository';
|
||||
export * from './domain/repositories/IUserRatingRepository';
|
||||
export * from './domain/repositories/IAchievementRepository';
|
||||
|
||||
export * from './infrastructure/repositories/InMemoryUserRatingRepository';
|
||||
export * from './infrastructure/repositories/InMemoryAchievementRepository';
|
||||
|
||||
export * from './application/dto/AuthenticatedUserDTO';
|
||||
export * from './application/dto/AuthSessionDTO';
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryAchievementRepository
|
||||
*
|
||||
* In-memory implementation of IAchievementRepository
|
||||
*/
|
||||
|
||||
import {
|
||||
Achievement,
|
||||
AchievementCategory,
|
||||
DRIVER_ACHIEVEMENTS,
|
||||
STEWARD_ACHIEVEMENTS,
|
||||
ADMIN_ACHIEVEMENTS,
|
||||
COMMUNITY_ACHIEVEMENTS,
|
||||
} from '../../domain/entities/Achievement';
|
||||
import { UserAchievement } from '../../domain/entities/UserAchievement';
|
||||
import type { IAchievementRepository } from '../../domain/repositories/IAchievementRepository';
|
||||
|
||||
export class InMemoryAchievementRepository implements IAchievementRepository {
|
||||
private achievements: Map<string, Achievement> = new Map();
|
||||
private userAchievements: Map<string, UserAchievement> = new Map();
|
||||
|
||||
constructor() {
|
||||
// Seed with predefined achievements
|
||||
this.seedAchievements();
|
||||
}
|
||||
|
||||
private seedAchievements(): void {
|
||||
const allAchievements = [
|
||||
...DRIVER_ACHIEVEMENTS,
|
||||
...STEWARD_ACHIEVEMENTS,
|
||||
...ADMIN_ACHIEVEMENTS,
|
||||
...COMMUNITY_ACHIEVEMENTS,
|
||||
];
|
||||
|
||||
for (const props of allAchievements) {
|
||||
const achievement = Achievement.create(props);
|
||||
this.achievements.set(achievement.id, achievement);
|
||||
}
|
||||
}
|
||||
|
||||
// Achievement operations
|
||||
async findAchievementById(id: string): Promise<Achievement | null> {
|
||||
return this.achievements.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findAllAchievements(): Promise<Achievement[]> {
|
||||
return Array.from(this.achievements.values());
|
||||
}
|
||||
|
||||
async findAchievementsByCategory(category: AchievementCategory): Promise<Achievement[]> {
|
||||
return Array.from(this.achievements.values())
|
||||
.filter(a => a.category === category);
|
||||
}
|
||||
|
||||
async createAchievement(achievement: Achievement): Promise<Achievement> {
|
||||
if (this.achievements.has(achievement.id)) {
|
||||
throw new Error('Achievement with this ID already exists');
|
||||
}
|
||||
this.achievements.set(achievement.id, achievement);
|
||||
return achievement;
|
||||
}
|
||||
|
||||
// UserAchievement operations
|
||||
async findUserAchievementById(id: string): Promise<UserAchievement | null> {
|
||||
return this.userAchievements.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findUserAchievementsByUserId(userId: string): Promise<UserAchievement[]> {
|
||||
return Array.from(this.userAchievements.values())
|
||||
.filter(ua => ua.userId === userId);
|
||||
}
|
||||
|
||||
async findUserAchievementByUserAndAchievement(
|
||||
userId: string,
|
||||
achievementId: string
|
||||
): Promise<UserAchievement | null> {
|
||||
for (const ua of this.userAchievements.values()) {
|
||||
if (ua.userId === userId && ua.achievementId === achievementId) {
|
||||
return ua;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async hasUserEarnedAchievement(userId: string, achievementId: string): Promise<boolean> {
|
||||
const ua = await this.findUserAchievementByUserAndAchievement(userId, achievementId);
|
||||
return ua !== null && ua.isComplete();
|
||||
}
|
||||
|
||||
async createUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement> {
|
||||
if (this.userAchievements.has(userAchievement.id)) {
|
||||
throw new Error('UserAchievement with this ID already exists');
|
||||
}
|
||||
this.userAchievements.set(userAchievement.id, userAchievement);
|
||||
return userAchievement;
|
||||
}
|
||||
|
||||
async updateUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement> {
|
||||
if (!this.userAchievements.has(userAchievement.id)) {
|
||||
throw new Error('UserAchievement not found');
|
||||
}
|
||||
this.userAchievements.set(userAchievement.id, userAchievement);
|
||||
return userAchievement;
|
||||
}
|
||||
|
||||
// Stats
|
||||
async getAchievementLeaderboard(limit: number): Promise<{ userId: string; points: number; count: number }[]> {
|
||||
const userStats = new Map<string, { points: number; count: number }>();
|
||||
|
||||
for (const ua of this.userAchievements.values()) {
|
||||
if (!ua.isComplete()) continue;
|
||||
|
||||
const achievement = this.achievements.get(ua.achievementId);
|
||||
if (!achievement) continue;
|
||||
|
||||
const existing = userStats.get(ua.userId) ?? { points: 0, count: 0 };
|
||||
userStats.set(ua.userId, {
|
||||
points: existing.points + achievement.points,
|
||||
count: existing.count + 1,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(userStats.entries())
|
||||
.map(([userId, stats]) => ({ userId, ...stats }))
|
||||
.sort((a, b) => b.points - a.points)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async getUserAchievementStats(userId: string): Promise<{
|
||||
total: number;
|
||||
points: number;
|
||||
byCategory: Record<AchievementCategory, number>
|
||||
}> {
|
||||
const userAchievements = await this.findUserAchievementsByUserId(userId);
|
||||
const completedAchievements = userAchievements.filter(ua => ua.isComplete());
|
||||
|
||||
const byCategory: Record<AchievementCategory, number> = {
|
||||
driver: 0,
|
||||
steward: 0,
|
||||
admin: 0,
|
||||
community: 0,
|
||||
};
|
||||
|
||||
let points = 0;
|
||||
|
||||
for (const ua of completedAchievements) {
|
||||
const achievement = this.achievements.get(ua.achievementId);
|
||||
if (achievement) {
|
||||
points += achievement.points;
|
||||
byCategory[achievement.category]++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: completedAchievements.length,
|
||||
points,
|
||||
byCategory,
|
||||
};
|
||||
}
|
||||
|
||||
// Test helpers
|
||||
clearUserAchievements(): void {
|
||||
this.userAchievements.clear();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.achievements.clear();
|
||||
this.userAchievements.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Infrastructure: InMemorySponsorAccountRepository
|
||||
*
|
||||
* In-memory implementation of ISponsorAccountRepository for development/testing.
|
||||
*/
|
||||
|
||||
import type { ISponsorAccountRepository } from '../../domain/repositories/ISponsorAccountRepository';
|
||||
import type { SponsorAccount } from '../../domain/entities/SponsorAccount';
|
||||
import type { UserId } from '../../domain/value-objects/UserId';
|
||||
|
||||
export class InMemorySponsorAccountRepository implements ISponsorAccountRepository {
|
||||
private accounts: Map<string, SponsorAccount> = new Map();
|
||||
|
||||
async save(account: SponsorAccount): Promise<void> {
|
||||
this.accounts.set(account.getId().value, account);
|
||||
}
|
||||
|
||||
async findById(id: UserId): Promise<SponsorAccount | null> {
|
||||
return this.accounts.get(id.value) ?? null;
|
||||
}
|
||||
|
||||
async findBySponsorId(sponsorId: string): Promise<SponsorAccount | null> {
|
||||
return Array.from(this.accounts.values()).find(
|
||||
a => a.getSponsorId() === sponsorId
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<SponsorAccount | null> {
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
return Array.from(this.accounts.values()).find(
|
||||
a => a.getEmail().toLowerCase() === normalizedEmail
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
async delete(id: UserId): Promise<void> {
|
||||
this.accounts.delete(id.value);
|
||||
}
|
||||
|
||||
// Helper for testing
|
||||
clear(): void {
|
||||
this.accounts.clear();
|
||||
}
|
||||
|
||||
// Helper for seeding demo data
|
||||
seed(accounts: SponsorAccount[]): void {
|
||||
accounts.forEach(a => this.accounts.set(a.getId().value, a));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryUserRatingRepository
|
||||
*
|
||||
* In-memory implementation of IUserRatingRepository
|
||||
*/
|
||||
|
||||
import { UserRating } from '../../domain/value-objects/UserRating';
|
||||
import type { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
|
||||
|
||||
export class InMemoryUserRatingRepository implements IUserRatingRepository {
|
||||
private ratings: Map<string, UserRating> = new Map();
|
||||
|
||||
async findByUserId(userId: string): Promise<UserRating | null> {
|
||||
return this.ratings.get(userId) ?? null;
|
||||
}
|
||||
|
||||
async findByUserIds(userIds: string[]): Promise<UserRating[]> {
|
||||
const results: UserRating[] = [];
|
||||
for (const userId of userIds) {
|
||||
const rating = this.ratings.get(userId);
|
||||
if (rating) {
|
||||
results.push(rating);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async save(rating: UserRating): Promise<UserRating> {
|
||||
this.ratings.set(rating.userId, rating);
|
||||
return rating;
|
||||
}
|
||||
|
||||
async getTopDrivers(limit: number): Promise<UserRating[]> {
|
||||
return Array.from(this.ratings.values())
|
||||
.filter(r => r.driver.sampleSize > 0)
|
||||
.sort((a, b) => b.driver.value - a.driver.value)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async getTopTrusted(limit: number): Promise<UserRating[]> {
|
||||
return Array.from(this.ratings.values())
|
||||
.filter(r => r.trust.sampleSize > 0)
|
||||
.sort((a, b) => b.trust.value - a.trust.value)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async getEligibleStewards(): Promise<UserRating[]> {
|
||||
return Array.from(this.ratings.values())
|
||||
.filter(r => r.canBeSteward());
|
||||
}
|
||||
|
||||
async findByDriverTier(tier: 'rookie' | 'amateur' | 'semi-pro' | 'pro' | 'elite'): Promise<UserRating[]> {
|
||||
return Array.from(this.ratings.values())
|
||||
.filter(r => r.getDriverTier() === tier);
|
||||
}
|
||||
|
||||
async delete(userId: string): Promise<void> {
|
||||
this.ratings.delete(userId);
|
||||
}
|
||||
|
||||
// Test helper
|
||||
clear(): void {
|
||||
this.ratings.clear();
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,13 @@ export type NotificationType =
|
||||
| 'team_invite' // You were invited to a team
|
||||
| 'team_join_request' // Someone requested to join your team
|
||||
| 'team_join_approved' // Your team join request was approved
|
||||
// Sponsorship-related
|
||||
| 'sponsorship_request_received' // A sponsor wants to sponsor you/your entity
|
||||
| 'sponsorship_request_accepted' // Your sponsorship request was accepted
|
||||
| 'sponsorship_request_rejected' // Your sponsorship request was rejected
|
||||
| 'sponsorship_request_withdrawn' // A sponsor withdrew their request
|
||||
| 'sponsorship_activated' // Sponsorship is now active
|
||||
| 'sponsorship_payment_received' // Payment received for sponsorship
|
||||
// System
|
||||
| 'system_announcement'; // System-wide announcement
|
||||
|
||||
@@ -60,6 +67,12 @@ export function getNotificationTypeTitle(type: NotificationType): string {
|
||||
team_invite: 'Team Invitation',
|
||||
team_join_request: 'Team Join Request',
|
||||
team_join_approved: 'Team Request Approved',
|
||||
sponsorship_request_received: 'Sponsorship Request',
|
||||
sponsorship_request_accepted: 'Sponsorship Accepted',
|
||||
sponsorship_request_rejected: 'Sponsorship Rejected',
|
||||
sponsorship_request_withdrawn: 'Sponsorship Withdrawn',
|
||||
sponsorship_activated: 'Sponsorship Active',
|
||||
sponsorship_payment_received: 'Payment Received',
|
||||
system_announcement: 'Announcement',
|
||||
};
|
||||
return titles[type];
|
||||
@@ -91,6 +104,12 @@ export function getNotificationTypePriority(type: NotificationType): number {
|
||||
team_invite: 5,
|
||||
team_join_request: 4,
|
||||
team_join_approved: 6,
|
||||
sponsorship_request_received: 7,
|
||||
sponsorship_request_accepted: 8,
|
||||
sponsorship_request_rejected: 6,
|
||||
sponsorship_request_withdrawn: 5,
|
||||
sponsorship_activated: 7,
|
||||
sponsorship_payment_received: 8,
|
||||
system_announcement: 10,
|
||||
};
|
||||
return priorities[type];
|
||||
|
||||
@@ -33,6 +33,13 @@ export * from './use-cases/GetRaceProtestsQuery';
|
||||
export * from './use-cases/GetRacePenaltiesQuery';
|
||||
export * from './use-cases/RequestProtestDefenseUseCase';
|
||||
export * from './use-cases/SubmitProtestDefenseUseCase';
|
||||
export * from './use-cases/GetSponsorDashboardQuery';
|
||||
export * from './use-cases/GetSponsorSponsorshipsQuery';
|
||||
export * from './use-cases/ApplyForSponsorshipUseCase';
|
||||
export * from './use-cases/AcceptSponsorshipRequestUseCase';
|
||||
export * from './use-cases/RejectSponsorshipRequestUseCase';
|
||||
export * from './use-cases/GetPendingSponsorshipRequestsQuery';
|
||||
export * from './use-cases/GetEntitySponsorshipPricingQuery';
|
||||
|
||||
// Export ports
|
||||
export * from './ports/DriverRatingProvider';
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Use Case: AcceptSponsorshipRequestUseCase
|
||||
*
|
||||
* Allows an entity owner to accept a sponsorship request.
|
||||
* This creates an active sponsorship and notifies the sponsor.
|
||||
*/
|
||||
|
||||
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
|
||||
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
|
||||
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
|
||||
|
||||
export interface AcceptSponsorshipRequestDTO {
|
||||
requestId: string;
|
||||
respondedBy: string; // driverId of the person accepting
|
||||
}
|
||||
|
||||
export interface AcceptSponsorshipRequestResultDTO {
|
||||
requestId: string;
|
||||
sponsorshipId: string;
|
||||
status: 'accepted';
|
||||
acceptedAt: Date;
|
||||
platformFee: number;
|
||||
netAmount: number;
|
||||
}
|
||||
|
||||
export class AcceptSponsorshipRequestUseCase {
|
||||
constructor(
|
||||
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
|
||||
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
|
||||
) {}
|
||||
|
||||
async execute(dto: AcceptSponsorshipRequestDTO): Promise<AcceptSponsorshipRequestResultDTO> {
|
||||
// Find the request
|
||||
const request = await this.sponsorshipRequestRepo.findById(dto.requestId);
|
||||
if (!request) {
|
||||
throw new Error('Sponsorship request not found');
|
||||
}
|
||||
|
||||
if (!request.isPending()) {
|
||||
throw new Error(`Cannot accept a ${request.status} sponsorship request`);
|
||||
}
|
||||
|
||||
// Accept the request
|
||||
const acceptedRequest = request.accept(dto.respondedBy);
|
||||
await this.sponsorshipRequestRepo.update(acceptedRequest);
|
||||
|
||||
// If this is a season sponsorship, create the SeasonSponsorship record
|
||||
let sponsorshipId = `spons_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
if (request.entityType === 'season') {
|
||||
const sponsorship = SeasonSponsorship.create({
|
||||
id: sponsorshipId,
|
||||
seasonId: request.entityId,
|
||||
sponsorId: request.sponsorId,
|
||||
tier: request.tier,
|
||||
pricing: request.offeredAmount,
|
||||
status: 'active',
|
||||
});
|
||||
await this.seasonSponsorshipRepo.create(sponsorship);
|
||||
}
|
||||
|
||||
// TODO: In a real implementation, we would:
|
||||
// 1. Create notification for the sponsor
|
||||
// 2. Process payment
|
||||
// 3. Update wallet balances
|
||||
|
||||
return {
|
||||
requestId: acceptedRequest.id,
|
||||
sponsorshipId,
|
||||
status: 'accepted',
|
||||
acceptedAt: acceptedRequest.respondedAt!,
|
||||
platformFee: acceptedRequest.getPlatformFee().amount,
|
||||
netAmount: acceptedRequest.getNetAmount().amount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Use Case: ApplyForSponsorshipUseCase
|
||||
*
|
||||
* Allows a sponsor to apply for a sponsorship slot on any entity
|
||||
* (driver, team, race, or season/league).
|
||||
*/
|
||||
|
||||
import { SponsorshipRequest, type SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
|
||||
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
|
||||
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
|
||||
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
|
||||
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
|
||||
import { Money, type Currency } from '../../domain/value-objects/Money';
|
||||
|
||||
export interface ApplyForSponsorshipDTO {
|
||||
sponsorId: string;
|
||||
entityType: SponsorableEntityType;
|
||||
entityId: string;
|
||||
tier: SponsorshipTier;
|
||||
offeredAmount: number; // in cents
|
||||
currency?: Currency;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ApplyForSponsorshipResultDTO {
|
||||
requestId: string;
|
||||
status: 'pending';
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class ApplyForSponsorshipUseCase {
|
||||
constructor(
|
||||
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
|
||||
private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository,
|
||||
private readonly sponsorRepo: ISponsorRepository,
|
||||
) {}
|
||||
|
||||
async execute(dto: ApplyForSponsorshipDTO): Promise<ApplyForSponsorshipResultDTO> {
|
||||
// Validate sponsor exists
|
||||
const sponsor = await this.sponsorRepo.findById(dto.sponsorId);
|
||||
if (!sponsor) {
|
||||
throw new Error('Sponsor not found');
|
||||
}
|
||||
|
||||
// Check if entity accepts sponsorship applications
|
||||
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
|
||||
if (!pricing) {
|
||||
throw new Error('This entity has not set up sponsorship pricing');
|
||||
}
|
||||
|
||||
if (!pricing.acceptingApplications) {
|
||||
throw new Error('This entity is not currently accepting sponsorship applications');
|
||||
}
|
||||
|
||||
// Check if the requested tier slot is available
|
||||
const slotAvailable = pricing.isSlotAvailable(dto.tier);
|
||||
if (!slotAvailable) {
|
||||
throw new Error(`No ${dto.tier} sponsorship slots are available`);
|
||||
}
|
||||
|
||||
// Check if sponsor already has a pending request for this entity
|
||||
const hasPending = await this.sponsorshipRequestRepo.hasPendingRequest(
|
||||
dto.sponsorId,
|
||||
dto.entityType,
|
||||
dto.entityId
|
||||
);
|
||||
if (hasPending) {
|
||||
throw new Error('You already have a pending sponsorship request for this entity');
|
||||
}
|
||||
|
||||
// Validate offered amount meets minimum price
|
||||
const minPrice = pricing.getPrice(dto.tier);
|
||||
if (minPrice && dto.offeredAmount < minPrice.amount) {
|
||||
throw new Error(`Offered amount must be at least ${minPrice.format()}`);
|
||||
}
|
||||
|
||||
// Create the sponsorship request
|
||||
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const offeredAmount = Money.create(dto.offeredAmount, dto.currency ?? 'USD');
|
||||
|
||||
const request = SponsorshipRequest.create({
|
||||
id: requestId,
|
||||
sponsorId: dto.sponsorId,
|
||||
entityType: dto.entityType,
|
||||
entityId: dto.entityId,
|
||||
tier: dto.tier,
|
||||
offeredAmount,
|
||||
message: dto.message,
|
||||
});
|
||||
|
||||
await this.sponsorshipRequestRepo.create(request);
|
||||
|
||||
return {
|
||||
requestId: request.id,
|
||||
status: 'pending',
|
||||
createdAt: request.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Query: GetEntitySponsorshipPricingQuery
|
||||
*
|
||||
* Retrieves sponsorship pricing configuration for any entity.
|
||||
* Used by sponsors to see available slots and prices.
|
||||
*/
|
||||
|
||||
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
|
||||
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
|
||||
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
|
||||
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
|
||||
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
|
||||
|
||||
export interface GetEntitySponsorshipPricingDTO {
|
||||
entityType: SponsorableEntityType;
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
export interface SponsorshipSlotDTO {
|
||||
tier: SponsorshipTier;
|
||||
price: number;
|
||||
currency: string;
|
||||
formattedPrice: string;
|
||||
benefits: string[];
|
||||
available: boolean;
|
||||
maxSlots: number;
|
||||
filledSlots: number;
|
||||
pendingRequests: number;
|
||||
}
|
||||
|
||||
export interface GetEntitySponsorshipPricingResultDTO {
|
||||
entityType: SponsorableEntityType;
|
||||
entityId: string;
|
||||
acceptingApplications: boolean;
|
||||
customRequirements?: string;
|
||||
mainSlot?: SponsorshipSlotDTO;
|
||||
secondarySlot?: SponsorshipSlotDTO;
|
||||
}
|
||||
|
||||
export class GetEntitySponsorshipPricingQuery {
|
||||
constructor(
|
||||
private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository,
|
||||
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
|
||||
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
|
||||
) {}
|
||||
|
||||
async execute(dto: GetEntitySponsorshipPricingDTO): Promise<GetEntitySponsorshipPricingResultDTO | null> {
|
||||
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
|
||||
|
||||
if (!pricing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Count pending requests by tier
|
||||
const pendingRequests = await this.sponsorshipRequestRepo.findPendingByEntity(
|
||||
dto.entityType,
|
||||
dto.entityId
|
||||
);
|
||||
const pendingMainCount = pendingRequests.filter(r => r.tier === 'main').length;
|
||||
const pendingSecondaryCount = pendingRequests.filter(r => r.tier === 'secondary').length;
|
||||
|
||||
// Count filled slots (for seasons, check SeasonSponsorship table)
|
||||
let filledMainSlots = 0;
|
||||
let filledSecondarySlots = 0;
|
||||
|
||||
if (dto.entityType === 'season') {
|
||||
const sponsorships = await this.seasonSponsorshipRepo.findBySeasonId(dto.entityId);
|
||||
const activeSponsorships = sponsorships.filter(s => s.isActive());
|
||||
filledMainSlots = activeSponsorships.filter(s => s.tier === 'main').length;
|
||||
filledSecondarySlots = activeSponsorships.filter(s => s.tier === 'secondary').length;
|
||||
}
|
||||
|
||||
const result: GetEntitySponsorshipPricingResultDTO = {
|
||||
entityType: dto.entityType,
|
||||
entityId: dto.entityId,
|
||||
acceptingApplications: pricing.acceptingApplications,
|
||||
customRequirements: pricing.customRequirements,
|
||||
};
|
||||
|
||||
if (pricing.mainSlot) {
|
||||
const mainMaxSlots = pricing.mainSlot.maxSlots;
|
||||
result.mainSlot = {
|
||||
tier: 'main',
|
||||
price: pricing.mainSlot.price.amount,
|
||||
currency: pricing.mainSlot.price.currency,
|
||||
formattedPrice: pricing.mainSlot.price.format(),
|
||||
benefits: pricing.mainSlot.benefits,
|
||||
available: pricing.mainSlot.available && filledMainSlots < mainMaxSlots,
|
||||
maxSlots: mainMaxSlots,
|
||||
filledSlots: filledMainSlots,
|
||||
pendingRequests: pendingMainCount,
|
||||
};
|
||||
}
|
||||
|
||||
if (pricing.secondarySlots) {
|
||||
const secondaryMaxSlots = pricing.secondarySlots.maxSlots;
|
||||
result.secondarySlot = {
|
||||
tier: 'secondary',
|
||||
price: pricing.secondarySlots.price.amount,
|
||||
currency: pricing.secondarySlots.price.currency,
|
||||
formattedPrice: pricing.secondarySlots.price.format(),
|
||||
benefits: pricing.secondarySlots.benefits,
|
||||
available: pricing.secondarySlots.available && filledSecondarySlots < secondaryMaxSlots,
|
||||
maxSlots: secondaryMaxSlots,
|
||||
filledSlots: filledSecondarySlots,
|
||||
pendingRequests: pendingSecondaryCount,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,16 @@ export class GetLeagueFullConfigQuery {
|
||||
sessionCount,
|
||||
roundsPlanned,
|
||||
},
|
||||
stewarding: {
|
||||
decisionMode: 'admin_only',
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 48,
|
||||
voteTimeLimit: 72,
|
||||
protestDeadlineHours: 72,
|
||||
stewardingClosesHours: 168,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
},
|
||||
};
|
||||
|
||||
return form;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Query: GetPendingSponsorshipRequestsQuery
|
||||
*
|
||||
* Retrieves pending sponsorship requests for an entity owner to review.
|
||||
*/
|
||||
|
||||
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
|
||||
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
|
||||
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
|
||||
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
|
||||
|
||||
export interface GetPendingSponsorshipRequestsDTO {
|
||||
entityType: SponsorableEntityType;
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
export interface PendingSponsorshipRequestDTO {
|
||||
id: string;
|
||||
sponsorId: string;
|
||||
sponsorName: string;
|
||||
sponsorLogo?: string;
|
||||
tier: SponsorshipTier;
|
||||
offeredAmount: number;
|
||||
currency: string;
|
||||
formattedAmount: string;
|
||||
message?: string;
|
||||
createdAt: Date;
|
||||
platformFee: number;
|
||||
netAmount: number;
|
||||
}
|
||||
|
||||
export interface GetPendingSponsorshipRequestsResultDTO {
|
||||
entityType: SponsorableEntityType;
|
||||
entityId: string;
|
||||
requests: PendingSponsorshipRequestDTO[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export class GetPendingSponsorshipRequestsQuery {
|
||||
constructor(
|
||||
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
|
||||
private readonly sponsorRepo: ISponsorRepository,
|
||||
) {}
|
||||
|
||||
async execute(dto: GetPendingSponsorshipRequestsDTO): Promise<GetPendingSponsorshipRequestsResultDTO> {
|
||||
const requests = await this.sponsorshipRequestRepo.findPendingByEntity(
|
||||
dto.entityType,
|
||||
dto.entityId
|
||||
);
|
||||
|
||||
const requestDTOs: PendingSponsorshipRequestDTO[] = [];
|
||||
|
||||
for (const request of requests) {
|
||||
const sponsor = await this.sponsorRepo.findById(request.sponsorId);
|
||||
|
||||
requestDTOs.push({
|
||||
id: request.id,
|
||||
sponsorId: request.sponsorId,
|
||||
sponsorName: sponsor?.name ?? 'Unknown Sponsor',
|
||||
sponsorLogo: sponsor?.logoUrl,
|
||||
tier: request.tier,
|
||||
offeredAmount: request.offeredAmount.amount,
|
||||
currency: request.offeredAmount.currency,
|
||||
formattedAmount: request.offeredAmount.format(),
|
||||
message: request.message,
|
||||
createdAt: request.createdAt,
|
||||
platformFee: request.getPlatformFee().amount,
|
||||
netAmount: request.getNetAmount().amount,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by creation date (newest first)
|
||||
requestDTOs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
return {
|
||||
entityType: dto.entityType,
|
||||
entityId: dto.entityId,
|
||||
requests: requestDTOs,
|
||||
totalCount: requestDTOs.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Application Query: GetSponsorDashboardQuery
|
||||
*
|
||||
* Returns sponsor dashboard metrics including sponsorships, impressions, and investment data.
|
||||
*/
|
||||
|
||||
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
|
||||
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
|
||||
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
|
||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||
|
||||
export interface GetSponsorDashboardQueryParams {
|
||||
sponsorId: string;
|
||||
}
|
||||
|
||||
export interface SponsoredLeagueDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
tier: 'main' | 'secondary';
|
||||
drivers: number;
|
||||
races: number;
|
||||
impressions: number;
|
||||
status: 'active' | 'upcoming' | 'completed';
|
||||
}
|
||||
|
||||
export interface SponsorDashboardDTO {
|
||||
sponsorId: string;
|
||||
sponsorName: string;
|
||||
metrics: {
|
||||
impressions: number;
|
||||
impressionsChange: number;
|
||||
uniqueViewers: number;
|
||||
viewersChange: number;
|
||||
races: number;
|
||||
drivers: number;
|
||||
exposure: number;
|
||||
exposureChange: number;
|
||||
};
|
||||
sponsoredLeagues: SponsoredLeagueDTO[];
|
||||
investment: {
|
||||
activeSponsorships: number;
|
||||
totalInvestment: number;
|
||||
costPerThousandViews: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class GetSponsorDashboardQuery {
|
||||
constructor(
|
||||
private readonly sponsorRepository: ISponsorRepository,
|
||||
private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository,
|
||||
private readonly seasonRepository: ISeasonRepository,
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
||||
private readonly raceRepository: IRaceRepository,
|
||||
) {}
|
||||
|
||||
async execute(params: GetSponsorDashboardQueryParams): Promise<SponsorDashboardDTO | null> {
|
||||
const { sponsorId } = params;
|
||||
|
||||
const sponsor = await this.sponsorRepository.findById(sponsorId);
|
||||
if (!sponsor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all sponsorships for this sponsor
|
||||
const sponsorships = await this.seasonSponsorshipRepository.findBySponsorId(sponsorId);
|
||||
|
||||
// Aggregate data across all sponsorships
|
||||
let totalImpressions = 0;
|
||||
let totalDrivers = 0;
|
||||
let totalRaces = 0;
|
||||
let totalInvestment = 0;
|
||||
const sponsoredLeagues: SponsoredLeagueDTO[] = [];
|
||||
const seenLeagues = new Set<string>();
|
||||
|
||||
for (const sponsorship of sponsorships) {
|
||||
// Get season to find league
|
||||
const season = await this.seasonRepository.findById(sponsorship.seasonId);
|
||||
if (!season) continue;
|
||||
|
||||
// Only process each league once
|
||||
if (seenLeagues.has(season.leagueId)) continue;
|
||||
seenLeagues.add(season.leagueId);
|
||||
|
||||
const league = await this.leagueRepository.findById(season.leagueId);
|
||||
if (!league) continue;
|
||||
|
||||
// Get membership count for this league
|
||||
const memberships = await this.leagueMembershipRepository.getLeagueMembers(season.leagueId);
|
||||
const driverCount = memberships.length;
|
||||
totalDrivers += driverCount;
|
||||
|
||||
// Get races for this league
|
||||
const races = await this.raceRepository.findByLeagueId(season.leagueId);
|
||||
const raceCount = races.length;
|
||||
totalRaces += raceCount;
|
||||
|
||||
// Calculate impressions based on completed races and drivers
|
||||
// This is a simplified calculation - in production would come from analytics
|
||||
const completedRaces = races.filter(r => r.status === 'completed').length;
|
||||
const leagueImpressions = completedRaces * driverCount * 100; // Simplified: 100 views per driver per race
|
||||
totalImpressions += leagueImpressions;
|
||||
|
||||
// Determine status based on season dates
|
||||
const now = new Date();
|
||||
let status: 'active' | 'upcoming' | 'completed' = 'active';
|
||||
if (season.endDate && season.endDate < now) {
|
||||
status = 'completed';
|
||||
} else if (season.startDate && season.startDate > now) {
|
||||
status = 'upcoming';
|
||||
}
|
||||
|
||||
// Add investment
|
||||
totalInvestment += sponsorship.pricing.amount;
|
||||
|
||||
sponsoredLeagues.push({
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
tier: sponsorship.tier,
|
||||
drivers: driverCount,
|
||||
races: raceCount,
|
||||
impressions: leagueImpressions,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
const activeSponsorships = sponsorships.filter(s => s.status === 'active').length;
|
||||
const costPerThousandViews = totalImpressions > 0
|
||||
? (totalInvestment / (totalImpressions / 1000))
|
||||
: 0;
|
||||
|
||||
// Calculate unique viewers (simplified: assume 70% of impressions are unique)
|
||||
const uniqueViewers = Math.round(totalImpressions * 0.7);
|
||||
|
||||
// Calculate exposure score (0-100 based on tier distribution)
|
||||
const mainSponsorships = sponsorships.filter(s => s.tier === 'main').length;
|
||||
const exposure = sponsorships.length > 0
|
||||
? Math.min(100, (mainSponsorships * 30) + (sponsorships.length * 10))
|
||||
: 0;
|
||||
|
||||
return {
|
||||
sponsorId,
|
||||
sponsorName: sponsor.name,
|
||||
metrics: {
|
||||
impressions: totalImpressions,
|
||||
impressionsChange: 12.5, // Would come from analytics comparison
|
||||
uniqueViewers,
|
||||
viewersChange: 8.3, // Would come from analytics comparison
|
||||
races: totalRaces,
|
||||
drivers: totalDrivers,
|
||||
exposure,
|
||||
exposureChange: 5.2, // Would come from analytics comparison
|
||||
},
|
||||
sponsoredLeagues,
|
||||
investment: {
|
||||
activeSponsorships,
|
||||
totalInvestment,
|
||||
costPerThousandViews: Math.round(costPerThousandViews * 100) / 100,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Application Query: GetSponsorSponsorshipsQuery
|
||||
*
|
||||
* Returns detailed sponsorship information for a sponsor's campaigns/sponsorships page.
|
||||
*/
|
||||
|
||||
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
|
||||
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
|
||||
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
|
||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||
import type { SponsorshipTier, SponsorshipStatus } from '../../domain/entities/SeasonSponsorship';
|
||||
|
||||
export interface GetSponsorSponsorshipsQueryParams {
|
||||
sponsorId: string;
|
||||
}
|
||||
|
||||
export interface SponsorshipDetailDTO {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
seasonId: string;
|
||||
seasonName: string;
|
||||
tier: SponsorshipTier;
|
||||
status: SponsorshipStatus;
|
||||
pricing: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
};
|
||||
platformFee: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
};
|
||||
netAmount: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
};
|
||||
metrics: {
|
||||
drivers: number;
|
||||
races: number;
|
||||
completedRaces: number;
|
||||
impressions: number;
|
||||
};
|
||||
createdAt: Date;
|
||||
activatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface SponsorSponsorshipsDTO {
|
||||
sponsorId: string;
|
||||
sponsorName: string;
|
||||
sponsorships: SponsorshipDetailDTO[];
|
||||
summary: {
|
||||
totalSponsorships: number;
|
||||
activeSponsorships: number;
|
||||
totalInvestment: number;
|
||||
totalPlatformFees: number;
|
||||
currency: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class GetSponsorSponsorshipsQuery {
|
||||
constructor(
|
||||
private readonly sponsorRepository: ISponsorRepository,
|
||||
private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository,
|
||||
private readonly seasonRepository: ISeasonRepository,
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
||||
private readonly raceRepository: IRaceRepository,
|
||||
) {}
|
||||
|
||||
async execute(params: GetSponsorSponsorshipsQueryParams): Promise<SponsorSponsorshipsDTO | null> {
|
||||
const { sponsorId } = params;
|
||||
|
||||
const sponsor = await this.sponsorRepository.findById(sponsorId);
|
||||
if (!sponsor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all sponsorships for this sponsor
|
||||
const sponsorships = await this.seasonSponsorshipRepository.findBySponsorId(sponsorId);
|
||||
|
||||
const sponsorshipDetails: SponsorshipDetailDTO[] = [];
|
||||
let totalInvestment = 0;
|
||||
let totalPlatformFees = 0;
|
||||
|
||||
for (const sponsorship of sponsorships) {
|
||||
// Get season to find league
|
||||
const season = await this.seasonRepository.findById(sponsorship.seasonId);
|
||||
if (!season) continue;
|
||||
|
||||
const league = await this.leagueRepository.findById(season.leagueId);
|
||||
if (!league) continue;
|
||||
|
||||
// Get membership count for this league
|
||||
const memberships = await this.leagueMembershipRepository.getLeagueMembers(season.leagueId);
|
||||
const driverCount = memberships.length;
|
||||
|
||||
// Get races for this league
|
||||
const races = await this.raceRepository.findByLeagueId(season.leagueId);
|
||||
const completedRaces = races.filter(r => r.status === 'completed').length;
|
||||
|
||||
// Calculate impressions
|
||||
const impressions = completedRaces * driverCount * 100;
|
||||
|
||||
// Calculate platform fee (10%)
|
||||
const platformFee = sponsorship.getPlatformFee();
|
||||
const netAmount = sponsorship.getNetAmount();
|
||||
|
||||
totalInvestment += sponsorship.pricing.amount;
|
||||
totalPlatformFees += platformFee.amount;
|
||||
|
||||
sponsorshipDetails.push({
|
||||
id: sponsorship.id,
|
||||
leagueId: league.id,
|
||||
leagueName: league.name,
|
||||
seasonId: season.id,
|
||||
seasonName: season.name,
|
||||
tier: sponsorship.tier,
|
||||
status: sponsorship.status,
|
||||
pricing: {
|
||||
amount: sponsorship.pricing.amount,
|
||||
currency: sponsorship.pricing.currency,
|
||||
},
|
||||
platformFee: {
|
||||
amount: platformFee.amount,
|
||||
currency: platformFee.currency,
|
||||
},
|
||||
netAmount: {
|
||||
amount: netAmount.amount,
|
||||
currency: netAmount.currency,
|
||||
},
|
||||
metrics: {
|
||||
drivers: driverCount,
|
||||
races: races.length,
|
||||
completedRaces,
|
||||
impressions,
|
||||
},
|
||||
createdAt: sponsorship.createdAt,
|
||||
activatedAt: sponsorship.activatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
const activeSponsorships = sponsorships.filter(s => s.status === 'active').length;
|
||||
|
||||
return {
|
||||
sponsorId,
|
||||
sponsorName: sponsor.name,
|
||||
sponsorships: sponsorshipDetails,
|
||||
summary: {
|
||||
totalSponsorships: sponsorships.length,
|
||||
activeSponsorships,
|
||||
totalInvestment,
|
||||
totalPlatformFees,
|
||||
currency: 'USD',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Use Case: RejectSponsorshipRequestUseCase
|
||||
*
|
||||
* Allows an entity owner to reject a sponsorship request.
|
||||
*/
|
||||
|
||||
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
|
||||
|
||||
export interface RejectSponsorshipRequestDTO {
|
||||
requestId: string;
|
||||
respondedBy: string; // driverId of the person rejecting
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface RejectSponsorshipRequestResultDTO {
|
||||
requestId: string;
|
||||
status: 'rejected';
|
||||
rejectedAt: Date;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class RejectSponsorshipRequestUseCase {
|
||||
constructor(
|
||||
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
|
||||
) {}
|
||||
|
||||
async execute(dto: RejectSponsorshipRequestDTO): Promise<RejectSponsorshipRequestResultDTO> {
|
||||
// Find the request
|
||||
const request = await this.sponsorshipRequestRepo.findById(dto.requestId);
|
||||
if (!request) {
|
||||
throw new Error('Sponsorship request not found');
|
||||
}
|
||||
|
||||
if (!request.isPending()) {
|
||||
throw new Error(`Cannot reject a ${request.status} sponsorship request`);
|
||||
}
|
||||
|
||||
// Reject the request
|
||||
const rejectedRequest = request.reject(dto.respondedBy, dto.reason);
|
||||
await this.sponsorshipRequestRepo.update(rejectedRequest);
|
||||
|
||||
// TODO: In a real implementation, notify the sponsor
|
||||
|
||||
return {
|
||||
requestId: rejectedRequest.id,
|
||||
status: 'rejected',
|
||||
rejectedAt: rejectedRequest.respondedAt!,
|
||||
reason: rejectedRequest.rejectionReason,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export interface DecalOverride {
|
||||
export interface DriverLiveryProps {
|
||||
id: string;
|
||||
driverId: string;
|
||||
gameId: string;
|
||||
carId: string;
|
||||
uploadedImageUrl: string;
|
||||
userDecals: LiveryDecal[];
|
||||
@@ -30,6 +31,7 @@ export interface DriverLiveryProps {
|
||||
export class DriverLivery {
|
||||
readonly id: string;
|
||||
readonly driverId: string;
|
||||
readonly gameId: string;
|
||||
readonly carId: string;
|
||||
readonly uploadedImageUrl: string;
|
||||
readonly userDecals: LiveryDecal[];
|
||||
@@ -41,6 +43,7 @@ export class DriverLivery {
|
||||
private constructor(props: DriverLiveryProps) {
|
||||
this.id = props.id;
|
||||
this.driverId = props.driverId;
|
||||
this.gameId = props.gameId;
|
||||
this.carId = props.carId;
|
||||
this.uploadedImageUrl = props.uploadedImageUrl;
|
||||
this.userDecals = props.userDecals;
|
||||
@@ -74,6 +77,10 @@ export class DriverLivery {
|
||||
throw new Error('DriverLivery driverId is required');
|
||||
}
|
||||
|
||||
if (!props.gameId || props.gameId.trim().length === 0) {
|
||||
throw new Error('DriverLivery gameId is required');
|
||||
}
|
||||
|
||||
if (!props.carId || props.carId.trim().length === 0) {
|
||||
throw new Error('DriverLivery carId is required');
|
||||
}
|
||||
|
||||
184
packages/racing/domain/entities/SponsorshipRequest.ts
Normal file
184
packages/racing/domain/entities/SponsorshipRequest.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Domain Entity: SponsorshipRequest
|
||||
*
|
||||
* Represents a sponsorship application from a Sponsor to any sponsorable entity
|
||||
* (driver, team, race, or league/season). The entity owner must approve/reject.
|
||||
*/
|
||||
|
||||
import type { Money } from '../value-objects/Money';
|
||||
import type { SponsorshipTier } from './SeasonSponsorship';
|
||||
|
||||
export type SponsorableEntityType = 'driver' | 'team' | 'race' | 'season';
|
||||
export type SponsorshipRequestStatus = 'pending' | 'accepted' | 'rejected' | 'withdrawn';
|
||||
|
||||
export interface SponsorshipRequestProps {
|
||||
id: string;
|
||||
sponsorId: string;
|
||||
entityType: SponsorableEntityType;
|
||||
entityId: string;
|
||||
tier: SponsorshipTier;
|
||||
offeredAmount: Money;
|
||||
message?: string;
|
||||
status: SponsorshipRequestStatus;
|
||||
createdAt: Date;
|
||||
respondedAt?: Date;
|
||||
respondedBy?: string; // driverId of the person who accepted/rejected
|
||||
rejectionReason?: string;
|
||||
}
|
||||
|
||||
export class SponsorshipRequest {
|
||||
readonly id: string;
|
||||
readonly sponsorId: string;
|
||||
readonly entityType: SponsorableEntityType;
|
||||
readonly entityId: string;
|
||||
readonly tier: SponsorshipTier;
|
||||
readonly offeredAmount: Money;
|
||||
readonly message?: string;
|
||||
readonly status: SponsorshipRequestStatus;
|
||||
readonly createdAt: Date;
|
||||
readonly respondedAt?: Date;
|
||||
readonly respondedBy?: string;
|
||||
readonly rejectionReason?: string;
|
||||
|
||||
private constructor(props: SponsorshipRequestProps) {
|
||||
this.id = props.id;
|
||||
this.sponsorId = props.sponsorId;
|
||||
this.entityType = props.entityType;
|
||||
this.entityId = props.entityId;
|
||||
this.tier = props.tier;
|
||||
this.offeredAmount = props.offeredAmount;
|
||||
this.message = props.message;
|
||||
this.status = props.status;
|
||||
this.createdAt = props.createdAt;
|
||||
this.respondedAt = props.respondedAt;
|
||||
this.respondedBy = props.respondedBy;
|
||||
this.rejectionReason = props.rejectionReason;
|
||||
}
|
||||
|
||||
static create(props: Omit<SponsorshipRequestProps, 'createdAt' | 'status'> & {
|
||||
createdAt?: Date;
|
||||
status?: SponsorshipRequestStatus;
|
||||
}): SponsorshipRequest {
|
||||
this.validate(props);
|
||||
|
||||
return new SponsorshipRequest({
|
||||
...props,
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
status: props.status ?? 'pending',
|
||||
});
|
||||
}
|
||||
|
||||
private static validate(props: Omit<SponsorshipRequestProps, 'createdAt' | 'status'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('SponsorshipRequest ID is required');
|
||||
}
|
||||
|
||||
if (!props.sponsorId || props.sponsorId.trim().length === 0) {
|
||||
throw new Error('SponsorshipRequest sponsorId is required');
|
||||
}
|
||||
|
||||
if (!props.entityType) {
|
||||
throw new Error('SponsorshipRequest entityType is required');
|
||||
}
|
||||
|
||||
if (!props.entityId || props.entityId.trim().length === 0) {
|
||||
throw new Error('SponsorshipRequest entityId is required');
|
||||
}
|
||||
|
||||
if (!props.tier) {
|
||||
throw new Error('SponsorshipRequest tier is required');
|
||||
}
|
||||
|
||||
if (!props.offeredAmount) {
|
||||
throw new Error('SponsorshipRequest offeredAmount is required');
|
||||
}
|
||||
|
||||
if (props.offeredAmount.amount <= 0) {
|
||||
throw new Error('SponsorshipRequest offeredAmount must be greater than zero');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept the sponsorship request
|
||||
*/
|
||||
accept(respondedBy: string): SponsorshipRequest {
|
||||
if (this.status !== 'pending') {
|
||||
throw new Error(`Cannot accept a ${this.status} sponsorship request`);
|
||||
}
|
||||
|
||||
if (!respondedBy || respondedBy.trim().length === 0) {
|
||||
throw new Error('respondedBy is required when accepting');
|
||||
}
|
||||
|
||||
return new SponsorshipRequest({
|
||||
...this,
|
||||
status: 'accepted',
|
||||
respondedAt: new Date(),
|
||||
respondedBy,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject the sponsorship request
|
||||
*/
|
||||
reject(respondedBy: string, reason?: string): SponsorshipRequest {
|
||||
if (this.status !== 'pending') {
|
||||
throw new Error(`Cannot reject a ${this.status} sponsorship request`);
|
||||
}
|
||||
|
||||
if (!respondedBy || respondedBy.trim().length === 0) {
|
||||
throw new Error('respondedBy is required when rejecting');
|
||||
}
|
||||
|
||||
return new SponsorshipRequest({
|
||||
...this,
|
||||
status: 'rejected',
|
||||
respondedAt: new Date(),
|
||||
respondedBy,
|
||||
rejectionReason: reason,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw the sponsorship request (by the sponsor)
|
||||
*/
|
||||
withdraw(): SponsorshipRequest {
|
||||
if (this.status !== 'pending') {
|
||||
throw new Error(`Cannot withdraw a ${this.status} sponsorship request`);
|
||||
}
|
||||
|
||||
return new SponsorshipRequest({
|
||||
...this,
|
||||
status: 'withdrawn',
|
||||
respondedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request is pending
|
||||
*/
|
||||
isPending(): boolean {
|
||||
return this.status === 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request was accepted
|
||||
*/
|
||||
isAccepted(): boolean {
|
||||
return this.status === 'accepted';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform fee for this request
|
||||
*/
|
||||
getPlatformFee(): Money {
|
||||
return this.offeredAmount.calculatePlatformFee();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get net amount after platform fee
|
||||
*/
|
||||
getNetAmount(): Money {
|
||||
return this.offeredAmount.calculateNetAmount();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export interface ILiveryRepository {
|
||||
findDriverLiveryById(id: string): Promise<DriverLivery | null>;
|
||||
findDriverLiveriesByDriverId(driverId: string): Promise<DriverLivery[]>;
|
||||
findDriverLiveryByDriverAndCar(driverId: string, carId: string): Promise<DriverLivery | null>;
|
||||
findDriverLiveriesByGameId(gameId: string): Promise<DriverLivery[]>;
|
||||
findDriverLiveryByDriverAndGame(driverId: string, gameId: string): Promise<DriverLivery[]>;
|
||||
createDriverLivery(livery: DriverLivery): Promise<DriverLivery>;
|
||||
updateDriverLivery(livery: DriverLivery): Promise<DriverLivery>;
|
||||
deleteDriverLivery(id: string): Promise<void>;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Repository Interface: ISponsorshipPricingRepository
|
||||
*
|
||||
* Stores sponsorship pricing configuration for any sponsorable entity.
|
||||
* This allows drivers, teams, races, and leagues to define their sponsorship slots.
|
||||
*/
|
||||
|
||||
import type { SponsorshipPricing } from '../value-objects/SponsorshipPricing';
|
||||
import type { SponsorableEntityType } from '../entities/SponsorshipRequest';
|
||||
|
||||
export interface ISponsorshipPricingRepository {
|
||||
/**
|
||||
* Get pricing configuration for an entity
|
||||
*/
|
||||
findByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipPricing | null>;
|
||||
|
||||
/**
|
||||
* Save or update pricing configuration for an entity
|
||||
*/
|
||||
save(entityType: SponsorableEntityType, entityId: string, pricing: SponsorshipPricing): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete pricing configuration for an entity
|
||||
*/
|
||||
delete(entityType: SponsorableEntityType, entityId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if entity has pricing configured
|
||||
*/
|
||||
exists(entityType: SponsorableEntityType, entityId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Find all entities accepting sponsorship applications
|
||||
*/
|
||||
findAcceptingApplications(entityType: SponsorableEntityType): Promise<Array<{
|
||||
entityId: string;
|
||||
pricing: SponsorshipPricing;
|
||||
}>>;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Repository Interface: ISponsorshipRequestRepository
|
||||
*
|
||||
* Defines operations for SponsorshipRequest aggregate persistence
|
||||
*/
|
||||
|
||||
import type { SponsorshipRequest, SponsorableEntityType, SponsorshipRequestStatus } from '../entities/SponsorshipRequest';
|
||||
|
||||
export interface ISponsorshipRequestRepository {
|
||||
findById(id: string): Promise<SponsorshipRequest | null>;
|
||||
|
||||
/**
|
||||
* Find all requests for a specific entity (driver, team, race, or season)
|
||||
*/
|
||||
findByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipRequest[]>;
|
||||
|
||||
/**
|
||||
* Find pending requests for an entity that need review
|
||||
*/
|
||||
findPendingByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipRequest[]>;
|
||||
|
||||
/**
|
||||
* Find all requests made by a sponsor
|
||||
*/
|
||||
findBySponsorId(sponsorId: string): Promise<SponsorshipRequest[]>;
|
||||
|
||||
/**
|
||||
* Find requests by status
|
||||
*/
|
||||
findByStatus(status: SponsorshipRequestStatus): Promise<SponsorshipRequest[]>;
|
||||
|
||||
/**
|
||||
* Find requests by sponsor and status
|
||||
*/
|
||||
findBySponsorIdAndStatus(sponsorId: string, status: SponsorshipRequestStatus): Promise<SponsorshipRequest[]>;
|
||||
|
||||
/**
|
||||
* Check if a sponsor already has a pending request for an entity
|
||||
*/
|
||||
hasPendingRequest(sponsorId: string, entityType: SponsorableEntityType, entityId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Count pending requests for an entity
|
||||
*/
|
||||
countPendingByEntity(entityType: SponsorableEntityType, entityId: string): Promise<number>;
|
||||
|
||||
create(request: SponsorshipRequest): Promise<SponsorshipRequest>;
|
||||
update(request: SponsorshipRequest): Promise<SponsorshipRequest>;
|
||||
delete(id: string): Promise<void>;
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export interface LiveryDecalProps {
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number; // Degrees, 0-360
|
||||
zIndex: number;
|
||||
type: DecalType;
|
||||
}
|
||||
@@ -23,6 +24,7 @@ export class LiveryDecal {
|
||||
readonly y: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly rotation: number;
|
||||
readonly zIndex: number;
|
||||
readonly type: DecalType;
|
||||
|
||||
@@ -33,13 +35,18 @@ export class LiveryDecal {
|
||||
this.y = props.y;
|
||||
this.width = props.width;
|
||||
this.height = props.height;
|
||||
this.rotation = props.rotation;
|
||||
this.zIndex = props.zIndex;
|
||||
this.type = props.type;
|
||||
}
|
||||
|
||||
static create(props: LiveryDecalProps): LiveryDecal {
|
||||
this.validate(props);
|
||||
return new LiveryDecal(props);
|
||||
static create(props: Omit<LiveryDecalProps, 'rotation'> & { rotation?: number }): LiveryDecal {
|
||||
const propsWithRotation = {
|
||||
...props,
|
||||
rotation: props.rotation ?? 0,
|
||||
};
|
||||
this.validate(propsWithRotation);
|
||||
return new LiveryDecal(propsWithRotation);
|
||||
}
|
||||
|
||||
private static validate(props: LiveryDecalProps): void {
|
||||
@@ -71,6 +78,10 @@ export class LiveryDecal {
|
||||
throw new Error('LiveryDecal zIndex must be a non-negative integer');
|
||||
}
|
||||
|
||||
if (props.rotation < 0 || props.rotation > 360) {
|
||||
throw new Error('LiveryDecal rotation must be between 0 and 360 degrees');
|
||||
}
|
||||
|
||||
if (!props.type) {
|
||||
throw new Error('LiveryDecal type is required');
|
||||
}
|
||||
@@ -108,6 +119,25 @@ export class LiveryDecal {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate decal
|
||||
*/
|
||||
rotate(rotation: number): LiveryDecal {
|
||||
// Normalize rotation to 0-360 range
|
||||
const normalizedRotation = ((rotation % 360) + 360) % 360;
|
||||
return LiveryDecal.create({
|
||||
...this,
|
||||
rotation: normalizedRotation,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS transform string for rendering
|
||||
*/
|
||||
getCssTransform(): string {
|
||||
return `rotate(${this.rotation}deg)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this decal overlaps with another
|
||||
*/
|
||||
|
||||
208
packages/racing/domain/value-objects/SponsorshipPricing.ts
Normal file
208
packages/racing/domain/value-objects/SponsorshipPricing.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Value Object: SponsorshipPricing
|
||||
*
|
||||
* Represents the sponsorship slot configuration and pricing for any sponsorable entity.
|
||||
* Used by drivers, teams, races, and leagues to define their sponsorship offerings.
|
||||
*/
|
||||
|
||||
import { Money } from './Money';
|
||||
|
||||
export interface SponsorshipSlotConfig {
|
||||
tier: 'main' | 'secondary';
|
||||
price: Money;
|
||||
benefits: string[];
|
||||
available: boolean;
|
||||
maxSlots: number; // How many sponsors of this tier can exist (1 for main, 2 for secondary typically)
|
||||
}
|
||||
|
||||
export interface SponsorshipPricingProps {
|
||||
mainSlot?: SponsorshipSlotConfig;
|
||||
secondarySlots?: SponsorshipSlotConfig;
|
||||
acceptingApplications: boolean;
|
||||
customRequirements?: string;
|
||||
}
|
||||
|
||||
export class SponsorshipPricing {
|
||||
readonly mainSlot?: SponsorshipSlotConfig;
|
||||
readonly secondarySlots?: SponsorshipSlotConfig;
|
||||
readonly acceptingApplications: boolean;
|
||||
readonly customRequirements?: string;
|
||||
|
||||
private constructor(props: SponsorshipPricingProps) {
|
||||
this.mainSlot = props.mainSlot;
|
||||
this.secondarySlots = props.secondarySlots;
|
||||
this.acceptingApplications = props.acceptingApplications;
|
||||
this.customRequirements = props.customRequirements;
|
||||
}
|
||||
|
||||
static create(props: Partial<SponsorshipPricingProps> = {}): SponsorshipPricing {
|
||||
return new SponsorshipPricing({
|
||||
mainSlot: props.mainSlot,
|
||||
secondarySlots: props.secondarySlots,
|
||||
acceptingApplications: props.acceptingApplications ?? true,
|
||||
customRequirements: props.customRequirements,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default pricing for a driver
|
||||
*/
|
||||
static defaultDriver(): SponsorshipPricing {
|
||||
return new SponsorshipPricing({
|
||||
mainSlot: {
|
||||
tier: 'main',
|
||||
price: Money.create(200, 'USD'),
|
||||
benefits: ['Suit logo', 'Helmet branding', 'Social mentions'],
|
||||
available: true,
|
||||
maxSlots: 1,
|
||||
},
|
||||
acceptingApplications: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default pricing for a team
|
||||
*/
|
||||
static defaultTeam(): SponsorshipPricing {
|
||||
return new SponsorshipPricing({
|
||||
mainSlot: {
|
||||
tier: 'main',
|
||||
price: Money.create(500, 'USD'),
|
||||
benefits: ['Team name suffix', 'Car livery', 'All driver suits'],
|
||||
available: true,
|
||||
maxSlots: 1,
|
||||
},
|
||||
secondarySlots: {
|
||||
tier: 'secondary',
|
||||
price: Money.create(250, 'USD'),
|
||||
benefits: ['Team page logo', 'Minor livery placement'],
|
||||
available: true,
|
||||
maxSlots: 2,
|
||||
},
|
||||
acceptingApplications: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default pricing for a race
|
||||
*/
|
||||
static defaultRace(): SponsorshipPricing {
|
||||
return new SponsorshipPricing({
|
||||
mainSlot: {
|
||||
tier: 'main',
|
||||
price: Money.create(300, 'USD'),
|
||||
benefits: ['Race title sponsor', 'Stream overlay', 'Results banner'],
|
||||
available: true,
|
||||
maxSlots: 1,
|
||||
},
|
||||
acceptingApplications: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default pricing for a league/season
|
||||
*/
|
||||
static defaultLeague(): SponsorshipPricing {
|
||||
return new SponsorshipPricing({
|
||||
mainSlot: {
|
||||
tier: 'main',
|
||||
price: Money.create(800, 'USD'),
|
||||
benefits: ['Hood placement', 'League banner', 'Prominent logo', 'League page URL'],
|
||||
available: true,
|
||||
maxSlots: 1,
|
||||
},
|
||||
secondarySlots: {
|
||||
tier: 'secondary',
|
||||
price: Money.create(250, 'USD'),
|
||||
benefits: ['Side logo placement', 'League page listing'],
|
||||
available: true,
|
||||
maxSlots: 2,
|
||||
},
|
||||
acceptingApplications: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific tier is available
|
||||
*/
|
||||
isSlotAvailable(tier: 'main' | 'secondary'): boolean {
|
||||
if (tier === 'main') {
|
||||
return !!this.mainSlot?.available;
|
||||
}
|
||||
return !!this.secondarySlots?.available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get price for a specific tier
|
||||
*/
|
||||
getPrice(tier: 'main' | 'secondary'): Money | null {
|
||||
if (tier === 'main') {
|
||||
return this.mainSlot?.price ?? null;
|
||||
}
|
||||
return this.secondarySlots?.price ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get benefits for a specific tier
|
||||
*/
|
||||
getBenefits(tier: 'main' | 'secondary'): string[] {
|
||||
if (tier === 'main') {
|
||||
return this.mainSlot?.benefits ?? [];
|
||||
}
|
||||
return this.secondarySlots?.benefits ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update main slot pricing
|
||||
*/
|
||||
updateMainSlot(config: Partial<SponsorshipSlotConfig>): SponsorshipPricing {
|
||||
const currentMain = this.mainSlot ?? {
|
||||
tier: 'main' as const,
|
||||
price: Money.create(0, 'USD'),
|
||||
benefits: [],
|
||||
available: true,
|
||||
maxSlots: 1,
|
||||
};
|
||||
|
||||
return new SponsorshipPricing({
|
||||
...this,
|
||||
mainSlot: {
|
||||
...currentMain,
|
||||
...config,
|
||||
tier: 'main',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update secondary slot pricing
|
||||
*/
|
||||
updateSecondarySlot(config: Partial<SponsorshipSlotConfig>): SponsorshipPricing {
|
||||
const currentSecondary = this.secondarySlots ?? {
|
||||
tier: 'secondary' as const,
|
||||
price: Money.create(0, 'USD'),
|
||||
benefits: [],
|
||||
available: true,
|
||||
maxSlots: 2,
|
||||
};
|
||||
|
||||
return new SponsorshipPricing({
|
||||
...this,
|
||||
secondarySlots: {
|
||||
...currentSecondary,
|
||||
...config,
|
||||
tier: 'secondary',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable accepting applications
|
||||
*/
|
||||
setAcceptingApplications(accepting: boolean): SponsorshipPricing {
|
||||
return new SponsorshipPricing({
|
||||
...this,
|
||||
acceptingApplications: accepting,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,30 @@ export * from './domain/repositories/IPenaltyRepository';
|
||||
|
||||
export * from './domain/services/StrengthOfFieldCalculator';
|
||||
|
||||
export * from './domain/value-objects/Money';
|
||||
export * from './domain/value-objects/SponsorshipPricing';
|
||||
|
||||
export * from './domain/entities/Sponsor';
|
||||
export * from './domain/entities/SeasonSponsorship';
|
||||
export * from './domain/entities/SponsorshipRequest';
|
||||
export * from './domain/repositories/ISponsorRepository';
|
||||
export * from './domain/repositories/ISeasonSponsorshipRepository';
|
||||
export * from './domain/repositories/ISponsorshipRequestRepository';
|
||||
export * from './domain/repositories/ISponsorshipPricingRepository';
|
||||
export * from './infrastructure/repositories/InMemorySponsorRepository';
|
||||
export * from './infrastructure/repositories/InMemorySeasonSponsorshipRepository';
|
||||
export * from './infrastructure/repositories/InMemorySponsorshipRequestRepository';
|
||||
export * from './infrastructure/repositories/InMemorySponsorshipPricingRepository';
|
||||
|
||||
export * from './application/mappers/EntityMappers';
|
||||
export * from './application/dto/DriverDTO';
|
||||
export * from './application/dto/LeagueDriverSeasonStatsDTO';
|
||||
export * from './application/dto/LeagueScoringConfigDTO';
|
||||
export * from './application/dto/LeagueScoringConfigDTO';
|
||||
|
||||
export * from './application/use-cases/GetSponsorDashboardQuery';
|
||||
export * from './application/use-cases/GetSponsorSponsorshipsQuery';
|
||||
export * from './application/use-cases/ApplyForSponsorshipUseCase';
|
||||
export * from './application/use-cases/AcceptSponsorshipRequestUseCase';
|
||||
export * from './application/use-cases/RejectSponsorshipRequestUseCase';
|
||||
export * from './application/use-cases/GetPendingSponsorshipRequestsQuery';
|
||||
export * from './application/use-cases/GetEntitySponsorshipPricingQuery';
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryGameRepository
|
||||
*
|
||||
* In-memory implementation of IGameRepository.
|
||||
*/
|
||||
|
||||
import { Game } from '../../domain/entities/Game';
|
||||
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
|
||||
|
||||
export class InMemoryGameRepository implements IGameRepository {
|
||||
private games: Map<string, Game>;
|
||||
|
||||
constructor(seedData?: Game[]) {
|
||||
this.games = new Map();
|
||||
|
||||
if (seedData) {
|
||||
seedData.forEach(game => {
|
||||
this.games.set(game.id, game);
|
||||
});
|
||||
} else {
|
||||
// Default seed data for common sim racing games
|
||||
const defaultGames = [
|
||||
Game.create({ id: 'iracing', name: 'iRacing' }),
|
||||
Game.create({ id: 'acc', name: 'Assetto Corsa Competizione' }),
|
||||
Game.create({ id: 'ac', name: 'Assetto Corsa' }),
|
||||
Game.create({ id: 'rf2', name: 'rFactor 2' }),
|
||||
Game.create({ id: 'ams2', name: 'Automobilista 2' }),
|
||||
Game.create({ id: 'lmu', name: 'Le Mans Ultimate' }),
|
||||
];
|
||||
defaultGames.forEach(game => {
|
||||
this.games.set(game.id, game);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Game | null> {
|
||||
return this.games.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Game[]> {
|
||||
return Array.from(this.games.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to add a game
|
||||
*/
|
||||
async create(game: Game): Promise<Game> {
|
||||
if (this.games.has(game.id)) {
|
||||
throw new Error(`Game with ID ${game.id} already exists`);
|
||||
}
|
||||
this.games.set(game.id, game);
|
||||
return game;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test helper to clear data
|
||||
*/
|
||||
clear(): void {
|
||||
this.games.clear();
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,16 @@ export class InMemoryLiveryRepository implements ILiveryRepository {
|
||||
return null;
|
||||
}
|
||||
|
||||
async findDriverLiveriesByGameId(gameId: string): Promise<DriverLivery[]> {
|
||||
return Array.from(this.driverLiveries.values()).filter(l => l.gameId === gameId);
|
||||
}
|
||||
|
||||
async findDriverLiveryByDriverAndGame(driverId: string, gameId: string): Promise<DriverLivery[]> {
|
||||
return Array.from(this.driverLiveries.values()).filter(
|
||||
l => l.driverId === driverId && l.gameId === gameId
|
||||
);
|
||||
}
|
||||
|
||||
async createDriverLivery(livery: DriverLivery): Promise<DriverLivery> {
|
||||
if (this.driverLiveries.has(livery.id)) {
|
||||
throw new Error('DriverLivery with this ID already exists');
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* InMemory implementation of ISponsorshipPricingRepository
|
||||
*/
|
||||
|
||||
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
|
||||
import { SponsorshipPricing } from '../../domain/value-objects/SponsorshipPricing';
|
||||
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
|
||||
|
||||
interface StorageKey {
|
||||
entityType: SponsorableEntityType;
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
export class InMemorySponsorshipPricingRepository implements ISponsorshipPricingRepository {
|
||||
private pricings: Map<string, { entityType: SponsorableEntityType; entityId: string; pricing: SponsorshipPricing }> = new Map();
|
||||
|
||||
private makeKey(entityType: SponsorableEntityType, entityId: string): string {
|
||||
return `${entityType}:${entityId}`;
|
||||
}
|
||||
|
||||
async findByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipPricing | null> {
|
||||
const key = this.makeKey(entityType, entityId);
|
||||
const entry = this.pricings.get(key);
|
||||
return entry?.pricing ?? null;
|
||||
}
|
||||
|
||||
async save(entityType: SponsorableEntityType, entityId: string, pricing: SponsorshipPricing): Promise<void> {
|
||||
const key = this.makeKey(entityType, entityId);
|
||||
this.pricings.set(key, { entityType, entityId, pricing });
|
||||
}
|
||||
|
||||
async delete(entityType: SponsorableEntityType, entityId: string): Promise<void> {
|
||||
const key = this.makeKey(entityType, entityId);
|
||||
this.pricings.delete(key);
|
||||
}
|
||||
|
||||
async exists(entityType: SponsorableEntityType, entityId: string): Promise<boolean> {
|
||||
const key = this.makeKey(entityType, entityId);
|
||||
return this.pricings.has(key);
|
||||
}
|
||||
|
||||
async findAcceptingApplications(entityType: SponsorableEntityType): Promise<Array<{
|
||||
entityId: string;
|
||||
pricing: SponsorshipPricing;
|
||||
}>> {
|
||||
return Array.from(this.pricings.values())
|
||||
.filter(entry => entry.entityType === entityType && entry.pricing.acceptingApplications)
|
||||
.map(entry => ({ entityId: entry.entityId, pricing: entry.pricing }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed initial data
|
||||
*/
|
||||
seed(data: Array<{ entityType: SponsorableEntityType; entityId: string; pricing: SponsorshipPricing }>): void {
|
||||
for (const entry of data) {
|
||||
const key = this.makeKey(entry.entityType, entry.entityId);
|
||||
this.pricings.set(key, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data (for testing)
|
||||
*/
|
||||
clear(): void {
|
||||
this.pricings.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* InMemory implementation of ISponsorshipRequestRepository
|
||||
*/
|
||||
|
||||
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
|
||||
import {
|
||||
SponsorshipRequest,
|
||||
type SponsorableEntityType,
|
||||
type SponsorshipRequestStatus
|
||||
} from '../../domain/entities/SponsorshipRequest';
|
||||
|
||||
export class InMemorySponsorshipRequestRepository implements ISponsorshipRequestRepository {
|
||||
private requests: Map<string, SponsorshipRequest> = new Map();
|
||||
|
||||
async findById(id: string): Promise<SponsorshipRequest | null> {
|
||||
return this.requests.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipRequest[]> {
|
||||
return Array.from(this.requests.values()).filter(
|
||||
request => request.entityType === entityType && request.entityId === entityId
|
||||
);
|
||||
}
|
||||
|
||||
async findPendingByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipRequest[]> {
|
||||
return Array.from(this.requests.values()).filter(
|
||||
request =>
|
||||
request.entityType === entityType &&
|
||||
request.entityId === entityId &&
|
||||
request.status === 'pending'
|
||||
);
|
||||
}
|
||||
|
||||
async findBySponsorId(sponsorId: string): Promise<SponsorshipRequest[]> {
|
||||
return Array.from(this.requests.values()).filter(
|
||||
request => request.sponsorId === sponsorId
|
||||
);
|
||||
}
|
||||
|
||||
async findByStatus(status: SponsorshipRequestStatus): Promise<SponsorshipRequest[]> {
|
||||
return Array.from(this.requests.values()).filter(
|
||||
request => request.status === status
|
||||
);
|
||||
}
|
||||
|
||||
async findBySponsorIdAndStatus(sponsorId: string, status: SponsorshipRequestStatus): Promise<SponsorshipRequest[]> {
|
||||
return Array.from(this.requests.values()).filter(
|
||||
request => request.sponsorId === sponsorId && request.status === status
|
||||
);
|
||||
}
|
||||
|
||||
async hasPendingRequest(sponsorId: string, entityType: SponsorableEntityType, entityId: string): Promise<boolean> {
|
||||
return Array.from(this.requests.values()).some(
|
||||
request =>
|
||||
request.sponsorId === sponsorId &&
|
||||
request.entityType === entityType &&
|
||||
request.entityId === entityId &&
|
||||
request.status === 'pending'
|
||||
);
|
||||
}
|
||||
|
||||
async countPendingByEntity(entityType: SponsorableEntityType, entityId: string): Promise<number> {
|
||||
return Array.from(this.requests.values()).filter(
|
||||
request =>
|
||||
request.entityType === entityType &&
|
||||
request.entityId === entityId &&
|
||||
request.status === 'pending'
|
||||
).length;
|
||||
}
|
||||
|
||||
async create(request: SponsorshipRequest): Promise<SponsorshipRequest> {
|
||||
this.requests.set(request.id, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async update(request: SponsorshipRequest): Promise<SponsorshipRequest> {
|
||||
if (!this.requests.has(request.id)) {
|
||||
throw new Error(`SponsorshipRequest ${request.id} not found`);
|
||||
}
|
||||
this.requests.set(request.id, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
this.requests.delete(id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return this.requests.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed initial data
|
||||
*/
|
||||
seed(requests: SponsorshipRequest[]): void {
|
||||
for (const request of requests) {
|
||||
this.requests.set(request.id, request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data (for testing)
|
||||
*/
|
||||
clear(): void {
|
||||
this.requests.clear();
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,63 @@ export interface DemoTeamDTO {
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo sponsor data for seeding
|
||||
*/
|
||||
export interface DemoSponsorDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
contactEmail: string;
|
||||
logoUrl: string;
|
||||
websiteUrl: string;
|
||||
tagline: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo season sponsorship data
|
||||
*/
|
||||
export interface DemoSeasonSponsorshipDTO {
|
||||
id: string;
|
||||
seasonId: string;
|
||||
sponsorId: string;
|
||||
tier: 'main' | 'secondary';
|
||||
pricingAmount: number;
|
||||
pricingCurrency: 'USD' | 'EUR' | 'GBP';
|
||||
status: 'pending' | 'active' | 'cancelled';
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo sponsorship request data for seeding pending requests
|
||||
*/
|
||||
export interface DemoSponsorshipRequestDTO {
|
||||
id: string;
|
||||
sponsorId: string;
|
||||
entityType: 'driver' | 'team' | 'race' | 'season';
|
||||
entityId: string;
|
||||
tier: 'main' | 'secondary';
|
||||
offeredAmount: number;
|
||||
currency: 'USD' | 'EUR' | 'GBP';
|
||||
message?: string;
|
||||
status: 'pending' | 'accepted' | 'rejected' | 'withdrawn';
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo sponsorship pricing configuration for entities
|
||||
*/
|
||||
export interface DemoSponsorshipPricingDTO {
|
||||
entityType: 'driver' | 'team' | 'race' | 'season';
|
||||
entityId: string;
|
||||
mainSlotPrice: number;
|
||||
mainSlotBenefits: string[];
|
||||
secondarySlotPrice?: number;
|
||||
secondarySlotBenefits?: string[];
|
||||
secondaryMaxSlots?: number;
|
||||
acceptingApplications: boolean;
|
||||
customRequirements?: string;
|
||||
}
|
||||
|
||||
export type RacingSeedData = {
|
||||
drivers: Driver[];
|
||||
leagues: League[];
|
||||
@@ -40,6 +97,10 @@ export type RacingSeedData = {
|
||||
friendships: Friendship[];
|
||||
feedEvents: FeedItem[];
|
||||
teams: DemoTeamDTO[];
|
||||
sponsors: DemoSponsorDTO[];
|
||||
seasonSponsorships: DemoSeasonSponsorshipDTO[];
|
||||
sponsorshipRequests: DemoSponsorshipRequestDTO[];
|
||||
sponsorshipPricings: DemoSponsorshipPricingDTO[];
|
||||
};
|
||||
|
||||
const POINTS_TABLE: Record<number, number> = {
|
||||
@@ -519,6 +580,316 @@ function createFeedEvents(
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo sponsors data - realistic sim racing sponsors
|
||||
*/
|
||||
const DEMO_SPONSORS: DemoSponsorDTO[] = [
|
||||
{
|
||||
id: 'sponsor-fanatec',
|
||||
name: 'Fanatec',
|
||||
contactEmail: 'partnerships@fanatec.com',
|
||||
logoUrl: '/images/sponsors/fanatec.svg',
|
||||
websiteUrl: 'https://fanatec.com',
|
||||
tagline: 'The world\'s leading sim racing hardware',
|
||||
},
|
||||
{
|
||||
id: 'sponsor-simucube',
|
||||
name: 'Simucube',
|
||||
contactEmail: 'sponsors@simucube.com',
|
||||
logoUrl: '/images/sponsors/simucube.svg',
|
||||
websiteUrl: 'https://simucube.com',
|
||||
tagline: 'Professional Direct Drive Wheels',
|
||||
},
|
||||
{
|
||||
id: 'sponsor-heusinkveld',
|
||||
name: 'Heusinkveld',
|
||||
contactEmail: 'info@heusinkveld.com',
|
||||
logoUrl: '/images/sponsors/heusinkveld.svg',
|
||||
websiteUrl: 'https://heusinkveld.com',
|
||||
tagline: 'Sim Racing Pedals & Hardware',
|
||||
},
|
||||
{
|
||||
id: 'sponsor-trak-racer',
|
||||
name: 'Trak Racer',
|
||||
contactEmail: 'partnerships@trakracer.com',
|
||||
logoUrl: '/images/sponsors/trak-racer.svg',
|
||||
websiteUrl: 'https://trakracer.com',
|
||||
tagline: 'Premium Racing Simulators & Cockpits',
|
||||
},
|
||||
{
|
||||
id: 'sponsor-simlab',
|
||||
name: 'Sim-Lab',
|
||||
contactEmail: 'sponsor@sim-lab.eu',
|
||||
logoUrl: '/images/sponsors/simlab.svg',
|
||||
websiteUrl: 'https://sim-lab.eu',
|
||||
tagline: 'Aluminum Profile Sim Racing Rigs',
|
||||
},
|
||||
{
|
||||
id: 'sponsor-motionrig',
|
||||
name: 'MotionRig Pro',
|
||||
contactEmail: 'business@motionrigpro.com',
|
||||
logoUrl: '/images/sponsors/motionrig.svg',
|
||||
websiteUrl: 'https://motionrigpro.com',
|
||||
tagline: 'Feel every turn, every bump',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Create season sponsorships linking sponsors to leagues
|
||||
*/
|
||||
function createSeasonSponsorships(
|
||||
leagues: League[],
|
||||
sponsors: DemoSponsorDTO[],
|
||||
): DemoSeasonSponsorshipDTO[] {
|
||||
const sponsorships: DemoSeasonSponsorshipDTO[] = [];
|
||||
|
||||
// GridPilot Sprint Series - sponsored by Fanatec (main) + Heusinkveld & Simucube (secondary)
|
||||
const sprintLeague = leagues.find(l => l.name === 'GridPilot Sprint Series');
|
||||
if (sprintLeague) {
|
||||
sponsorships.push({
|
||||
id: `sponsorship-${sprintLeague.id}-fanatec`,
|
||||
seasonId: `season-${sprintLeague.id}-demo`,
|
||||
sponsorId: 'sponsor-fanatec',
|
||||
tier: 'main',
|
||||
pricingAmount: 5000,
|
||||
pricingCurrency: 'USD',
|
||||
status: 'active',
|
||||
description: 'Main sponsor for the Sprint Series - premium wheel branding',
|
||||
});
|
||||
sponsorships.push({
|
||||
id: `sponsorship-${sprintLeague.id}-heusinkveld`,
|
||||
seasonId: `season-${sprintLeague.id}-demo`,
|
||||
sponsorId: 'sponsor-heusinkveld',
|
||||
tier: 'secondary',
|
||||
pricingAmount: 2000,
|
||||
pricingCurrency: 'USD',
|
||||
status: 'active',
|
||||
});
|
||||
sponsorships.push({
|
||||
id: `sponsorship-${sprintLeague.id}-simucube`,
|
||||
seasonId: `season-${sprintLeague.id}-demo`,
|
||||
sponsorId: 'sponsor-simucube',
|
||||
tier: 'secondary',
|
||||
pricingAmount: 2000,
|
||||
pricingCurrency: 'USD',
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
|
||||
// GridPilot Endurance Cup - sponsored by Trak Racer (main) + Sim-Lab (secondary)
|
||||
const enduranceLeague = leagues.find(l => l.name === 'GridPilot Endurance Cup');
|
||||
if (enduranceLeague) {
|
||||
sponsorships.push({
|
||||
id: `sponsorship-${enduranceLeague.id}-trakracer`,
|
||||
seasonId: `season-${enduranceLeague.id}-demo`,
|
||||
sponsorId: 'sponsor-trak-racer',
|
||||
tier: 'main',
|
||||
pricingAmount: 7500,
|
||||
pricingCurrency: 'USD',
|
||||
status: 'active',
|
||||
description: 'Endurance series naming rights',
|
||||
});
|
||||
sponsorships.push({
|
||||
id: `sponsorship-${enduranceLeague.id}-simlab`,
|
||||
seasonId: `season-${enduranceLeague.id}-demo`,
|
||||
sponsorId: 'sponsor-simlab',
|
||||
tier: 'secondary',
|
||||
pricingAmount: 3000,
|
||||
pricingCurrency: 'USD',
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
|
||||
// GridPilot Club Ladder - sponsored by MotionRig Pro (main)
|
||||
const clubLeague = leagues.find(l => l.name === 'GridPilot Club Ladder');
|
||||
if (clubLeague) {
|
||||
sponsorships.push({
|
||||
id: `sponsorship-${clubLeague.id}-motionrig`,
|
||||
seasonId: `season-${clubLeague.id}-demo`,
|
||||
sponsorId: 'sponsor-motionrig',
|
||||
tier: 'main',
|
||||
pricingAmount: 3500,
|
||||
pricingCurrency: 'USD',
|
||||
status: 'active',
|
||||
description: 'Club ladder motion platform showcase',
|
||||
});
|
||||
}
|
||||
|
||||
return sponsorships;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create sponsorship pricing configurations for demo entities
|
||||
*/
|
||||
function createSponsorshipPricings(
|
||||
leagues: League[],
|
||||
teams: DemoTeamDTO[],
|
||||
drivers: Driver[],
|
||||
races: Race[],
|
||||
): DemoSponsorshipPricingDTO[] {
|
||||
const pricings: DemoSponsorshipPricingDTO[] = [];
|
||||
|
||||
// League/Season pricing - all leagues can accept sponsorships
|
||||
leagues.forEach((league, index) => {
|
||||
const basePrice = 500 + (index * 100);
|
||||
pricings.push({
|
||||
entityType: 'season',
|
||||
entityId: `season-${league.id}-demo`,
|
||||
mainSlotPrice: basePrice * 10, // Main sponsor is more expensive
|
||||
mainSlotBenefits: ['Hood placement', 'League banner', 'Prominent logo', 'League page URL'],
|
||||
secondarySlotPrice: basePrice * 3,
|
||||
secondarySlotBenefits: ['Side logo placement', 'League page listing'],
|
||||
secondaryMaxSlots: 2,
|
||||
acceptingApplications: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Team pricing - first 10 teams accept sponsorships
|
||||
teams.slice(0, 10).forEach((team, index) => {
|
||||
pricings.push({
|
||||
entityType: 'team',
|
||||
entityId: team.id,
|
||||
mainSlotPrice: 500 + (index * 50),
|
||||
mainSlotBenefits: ['Team name suffix', 'Car livery', 'All driver suits'],
|
||||
secondarySlotPrice: 250 + (index * 25),
|
||||
secondarySlotBenefits: ['Team page logo', 'Minor livery placement'],
|
||||
secondaryMaxSlots: 2,
|
||||
acceptingApplications: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Driver pricing - first 20 drivers accept sponsorships
|
||||
drivers.slice(0, 20).forEach((driver, index) => {
|
||||
pricings.push({
|
||||
entityType: 'driver',
|
||||
entityId: driver.id,
|
||||
mainSlotPrice: 200 + (index * 20),
|
||||
mainSlotBenefits: ['Suit logo', 'Helmet branding', 'Social mentions'],
|
||||
acceptingApplications: index % 3 !== 0, // Some drivers not accepting
|
||||
});
|
||||
});
|
||||
|
||||
// Race pricing - upcoming races can have title sponsors
|
||||
const upcomingRaces = races.filter(r => r.status === 'scheduled').slice(0, 10);
|
||||
upcomingRaces.forEach((race, index) => {
|
||||
pricings.push({
|
||||
entityType: 'race',
|
||||
entityId: race.id,
|
||||
mainSlotPrice: 300 + (index * 30),
|
||||
mainSlotBenefits: ['Race title sponsor', 'Stream overlay', 'Results banner'],
|
||||
acceptingApplications: true,
|
||||
});
|
||||
});
|
||||
|
||||
return pricings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create demo sponsorship requests (some pending, some accepted/rejected)
|
||||
*/
|
||||
function createSponsorshipRequests(
|
||||
sponsors: DemoSponsorDTO[],
|
||||
leagues: League[],
|
||||
teams: DemoTeamDTO[],
|
||||
drivers: Driver[],
|
||||
races: Race[],
|
||||
): DemoSponsorshipRequestDTO[] {
|
||||
const requests: DemoSponsorshipRequestDTO[] = [];
|
||||
const now = new Date();
|
||||
|
||||
// Pending request: Simucube wants to sponsor a driver
|
||||
requests.push({
|
||||
id: 'req-simucube-driver-1',
|
||||
sponsorId: 'sponsor-simucube',
|
||||
entityType: 'driver',
|
||||
entityId: drivers[5].id,
|
||||
tier: 'main',
|
||||
offeredAmount: 25000, // $250.00 in cents
|
||||
currency: 'USD',
|
||||
message: 'We would love to sponsor your racing career! Simucube offers the best direct drive wheels in sim racing.',
|
||||
status: 'pending',
|
||||
createdAt: new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000), // 2 days ago
|
||||
});
|
||||
|
||||
// Pending request: Heusinkveld wants to sponsor a team
|
||||
requests.push({
|
||||
id: 'req-heusinkveld-team-1',
|
||||
sponsorId: 'sponsor-heusinkveld',
|
||||
entityType: 'team',
|
||||
entityId: teams[2].id,
|
||||
tier: 'main',
|
||||
offeredAmount: 55000, // $550.00 in cents
|
||||
currency: 'USD',
|
||||
message: 'Heusinkveld pedals are known for their precision. We believe your team embodies the same values.',
|
||||
status: 'pending',
|
||||
createdAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000), // 3 days ago
|
||||
});
|
||||
|
||||
// Pending request: Trak Racer wants to sponsor a race
|
||||
const upcomingRace = races.find(r => r.status === 'scheduled');
|
||||
if (upcomingRace) {
|
||||
requests.push({
|
||||
id: 'req-trakracer-race-1',
|
||||
sponsorId: 'sponsor-trak-racer',
|
||||
entityType: 'race',
|
||||
entityId: upcomingRace.id,
|
||||
tier: 'main',
|
||||
offeredAmount: 35000, // $350.00 in cents
|
||||
currency: 'USD',
|
||||
message: 'We would like to be the title sponsor for this exciting race event!',
|
||||
status: 'pending',
|
||||
createdAt: new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000), // 1 day ago
|
||||
});
|
||||
}
|
||||
|
||||
// Pending request: MotionRig Pro wants secondary spot on a league
|
||||
const clubLeague = leagues.find(l => l.name === 'Sprint Challenge League');
|
||||
if (clubLeague) {
|
||||
requests.push({
|
||||
id: 'req-motionrig-league-1',
|
||||
sponsorId: 'sponsor-motionrig',
|
||||
entityType: 'season',
|
||||
entityId: `season-${clubLeague.id}-demo`,
|
||||
tier: 'secondary',
|
||||
offeredAmount: 150000, // $1500.00 in cents
|
||||
currency: 'USD',
|
||||
message: 'MotionRig Pro would love to be a secondary sponsor. Our motion platforms are perfect for your competitive drivers.',
|
||||
status: 'pending',
|
||||
createdAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), // 5 days ago
|
||||
});
|
||||
}
|
||||
|
||||
// Already accepted request (for history)
|
||||
requests.push({
|
||||
id: 'req-simlab-team-accepted',
|
||||
sponsorId: 'sponsor-simlab',
|
||||
entityType: 'team',
|
||||
entityId: teams[0].id,
|
||||
tier: 'secondary',
|
||||
offeredAmount: 30000,
|
||||
currency: 'USD',
|
||||
message: 'Sim-Lab rigs are the foundation of any competitive setup.',
|
||||
status: 'accepted',
|
||||
createdAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), // 30 days ago
|
||||
});
|
||||
|
||||
// Already rejected request (for history)
|
||||
requests.push({
|
||||
id: 'req-motionrig-driver-rejected',
|
||||
sponsorId: 'sponsor-motionrig',
|
||||
entityType: 'driver',
|
||||
entityId: drivers[10].id,
|
||||
tier: 'main',
|
||||
offeredAmount: 15000,
|
||||
currency: 'USD',
|
||||
message: 'Would you like to represent MotionRig Pro?',
|
||||
status: 'rejected',
|
||||
createdAt: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), // 20 days ago
|
||||
});
|
||||
|
||||
return requests;
|
||||
}
|
||||
|
||||
export function createStaticRacingSeed(seed: number): RacingSeedData {
|
||||
faker.seed(seed);
|
||||
|
||||
@@ -531,6 +902,10 @@ export function createStaticRacingSeed(seed: number): RacingSeedData {
|
||||
const friendships = createFriendships(drivers);
|
||||
const feedEvents = createFeedEvents(drivers, leagues, races, friendships);
|
||||
const standings = createStandings(leagues, results);
|
||||
const sponsors = DEMO_SPONSORS;
|
||||
const seasonSponsorships = createSeasonSponsorships(leagues, sponsors);
|
||||
const sponsorshipPricings = createSponsorshipPricings(leagues, teams, drivers, races);
|
||||
const sponsorshipRequests = createSponsorshipRequests(sponsors, leagues, teams, drivers, races);
|
||||
|
||||
return {
|
||||
drivers,
|
||||
@@ -542,6 +917,10 @@ export function createStaticRacingSeed(seed: number): RacingSeedData {
|
||||
friendships,
|
||||
feedEvents,
|
||||
teams,
|
||||
sponsors,
|
||||
seasonSponsorships,
|
||||
sponsorshipRequests,
|
||||
sponsorshipPricings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -568,6 +947,10 @@ export const teams = staticSeed.teams;
|
||||
export const memberships = staticSeed.memberships;
|
||||
export const friendships = staticSeed.friendships;
|
||||
export const feedEvents = staticSeed.feedEvents;
|
||||
export const sponsors = staticSeed.sponsors;
|
||||
export const seasonSponsorships = staticSeed.seasonSponsorships;
|
||||
export const sponsorshipRequests = staticSeed.sponsorshipRequests;
|
||||
export const sponsorshipPricings = staticSeed.sponsorshipPricings;
|
||||
|
||||
/**
|
||||
* Derived friend DTOs for UI consumption.
|
||||
|
||||
Reference in New Issue
Block a user