wip
This commit is contained in:
@@ -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'],
|
||||
},
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user