Files
gridpilot.gg/apps/website/components/leagues/LeagueAdmin.tsx
2025-12-11 00:57:32 +01:00

977 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import Button from '../ui/Button';
import Card from '../ui/Card';
import LeagueMembers from './LeagueMembers';
import ScheduleRaceForm from './ScheduleRaceForm';
import {
loadLeagueJoinRequests,
approveLeagueJoinRequest,
rejectLeagueJoinRequest,
loadLeagueOwnerSummary,
loadLeagueConfig,
loadLeagueProtests,
removeLeagueMember as removeLeagueMemberCommand,
updateLeagueMemberRole as updateLeagueMemberRoleCommand,
type LeagueJoinRequestViewModel,
type LeagueOwnerSummaryViewModel,
type LeagueAdminProtestsViewModel,
} from '@/lib/presenters/LeagueAdminPresenter';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import { LeagueBasicsSection } from './LeagueBasicsSection';
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 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, DollarSign, Wallet, Paintbrush, Trophy, Download, Car, Upload } from 'lucide-react';
type JoinRequest = LeagueJoinRequestViewModel;
interface LeagueAdminProps {
league: {
id: string;
ownerId: string;
settings: {
pointsSystem: string;
};
};
onLeagueUpdate?: () => void;
}
export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps) {
const router = useRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
const currentDriverId = useEffectiveDriverId();
const [joinRequests, setJoinRequests] = useState<JoinRequest[]>([]);
const [ownerSummary, setOwnerSummary] = useState<LeagueOwnerSummaryViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
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);
const [protestsViewModel, setProtestsViewModel] = useState<LeagueAdminProtestsViewModel | null>(null);
const [protestsLoading, setProtestsLoading] = useState(false);
const loadJoinRequests = useCallback(async () => {
setLoading(true);
try {
const requests = await loadLeagueJoinRequests(league.id);
setJoinRequests(requests);
} catch (err) {
console.error('Failed to load join requests:', err);
} finally {
setLoading(false);
}
}, [league.id]);
useEffect(() => {
loadJoinRequests();
}, [loadJoinRequests]);
useEffect(() => {
async function loadOwner() {
try {
const summary = await loadLeagueOwnerSummary(league);
setOwnerSummary(summary);
} catch (err) {
console.error('Failed to load league owner:', err);
}
}
loadOwner();
}, [league]);
useEffect(() => {
async function loadConfig() {
setConfigLoading(true);
try {
const configVm = await loadLeagueConfig(league.id);
setConfigForm(configVm.form);
} catch (err) {
console.error('Failed to load league config:', err);
} finally {
setConfigLoading(false);
}
}
loadConfig();
}, [league.id]);
// Load protests for this league's races
useEffect(() => {
async function loadProtests() {
setProtestsLoading(true);
try {
const vm = await loadLeagueProtests(league.id);
setProtestsViewModel(vm);
} catch (err) {
console.error('Failed to load protests:', err);
} finally {
setProtestsLoading(false);
}
}
if (activeTab === 'protests') {
loadProtests();
}
}, [league.id, activeTab]);
const handleApproveRequest = async (requestId: string) => {
try {
const updated = await approveLeagueJoinRequest(league.id, requestId);
setJoinRequests(updated);
onLeagueUpdate?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to approve request');
}
};
const handleRejectRequest = async (requestId: string, trimmedReason: string) => {
try {
// Alpha-only: we do not persist the reason yet, but we at least log it.
console.log('Join request rejected with reason:', {
requestId,
reason: trimmedReason,
});
const updated = await rejectLeagueJoinRequest(league.id, requestId);
setJoinRequests(updated);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reject request');
}
};
const handleRemoveMember = async (driverId: string) => {
if (!confirm('Are you sure you want to remove this member?')) {
return;
}
try {
await removeLeagueMemberCommand(league.id, currentDriverId, driverId);
onLeagueUpdate?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove member');
}
};
const handleUpdateRole = async (driverId: string, newRole: MembershipRole) => {
try {
await updateLeagueMemberRoleCommand(league.id, currentDriverId, driverId, newRole);
onLeagueUpdate?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update role');
}
};
const modal = searchParams?.get('modal');
const modalRequestId = searchParams?.get('requestId');
const activeRejectRequest =
modal === 'reject-request'
? joinRequests.find((r) => r.id === modalRequestId) ?? null
: null;
useEffect(() => {
if (!activeRejectRequest) {
setRejectReason('');
}
}, [activeRejectRequest, setRejectReason]);
const isRejectModalOpen = modal === 'reject-request' && !!activeRejectRequest;
const openRejectModal = (requestId: string) => {
const params = new URLSearchParams(searchParams?.toString());
params.set('modal', 'reject-request');
params.set('requestId', requestId);
const query = params.toString();
const url = query ? `${pathname}?${query}` : pathname;
router.push(url, { scroll: false });
};
const closeModal = () => {
const params = new URLSearchParams(searchParams?.toString());
params.delete('modal');
params.delete('requestId');
const query = params.toString();
const url = query ? `${pathname}?${query}` : pathname;
router.push(url, { scroll: false });
};
return (
<div>
{error && (
<div className="mb-6 p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400">
{error}
<button
onClick={() => setError(null)}
className="ml-4 text-sm underline hover:no-underline"
>
Dismiss
</button>
</div>
)}
{/* Admin Tabs */}
<div className="mb-6 border-b border-charcoal-outline">
<div className="flex gap-4 overflow-x-auto">
<button
onClick={() => setActiveTab('members')}
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'
}`}
>
Members
</button>
<button
onClick={() => setActiveTab('requests')}
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'
}`}
>
Requests
{joinRequests.length > 0 && (
<span className="px-2 py-0.5 text-xs bg-primary-blue text-white rounded-full">
{joinRequests.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab('races')}
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'
}`}
>
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 whitespace-nowrap ${
activeTab === 'protests'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Protests
</button>
<button
onClick={() => setActiveTab('settings')}
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'
}`}
>
Settings
</button>
</div>
</div>
{/* Tab Content */}
{activeTab === 'members' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Manage Members</h2>
<LeagueMembers
leagueId={league.id}
onRemoveMember={handleRemoveMember}
onUpdateRole={handleUpdateRole}
showActions={true}
/>
</Card>
)}
{activeTab === 'requests' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Join Requests</h2>
{loading ? (
<div className="text-center py-8 text-gray-400">Loading requests...</div>
) : joinRequests.length === 0 ? (
<div className="text-center py-8 text-gray-400">
No pending join requests
</div>
) : (
<div className="space-y-4">
{joinRequests.map((request) => {
const driver = request.driver;
const requestedOn = new Date(request.requestedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
const metaPieces = [
`Requested ${requestedOn}`,
request.message ? `Message: "${request.message}"` : null,
].filter(Boolean);
const meta = metaPieces.join(' • ');
return (
<div
key={request.id}
className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
>
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
{driver ? (
<DriverIdentity
driver={driver}
href={`/drivers/${request.driverId}?from=league-join-requests&leagueId=${league.id}`}
meta={meta}
size="sm"
/>
) : (
<div>
<h3 className="text-white font-medium">Unknown Driver</h3>
<p className="text-sm text-gray-400 mt-1">Unable to load driver details</p>
</div>
)}
</div>
<div className="flex gap-2 shrink-0">
<Button
variant="primary"
onClick={() => handleApproveRequest(request.id)}
className="px-4"
>
Approve
</Button>
<Button
variant="secondary"
onClick={() => openRejectModal(request.id)}
className="px-4"
>
Reject
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
</Card>
)}
{activeTab === 'races' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Schedule Race</h2>
<p className="text-gray-400 mb-6">
Create a new race for this league; this is an alpha-only in-memory scheduler.
</p>
<ScheduleRaceForm
preSelectedLeagueId={league.id}
onSuccess={(race) => {
router.push(`/races/${race.id}`);
}}
/>
</Card>
)}
{activeTab === 'protests' && (
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">Protests</h2>
<p className="text-sm text-gray-400 mt-1">
Review protests filed by drivers and manage steward decisions
</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>
{protestsLoading ? (
<div className="text-center py-12 text-gray-400">
<div className="animate-pulse">Loading protests...</div>
</div>
) : !protestsViewModel || protestsViewModel.protests.length === 0 ? (
<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">
<Flag className="w-8 h-8 text-gray-500" />
</div>
<h3 className="text-lg font-medium text-white mb-2">No Protests Filed</h3>
<p className="text-sm text-gray-400 max-w-md mx-auto">
When drivers file protests for incidents during races, they will appear here for steward review.
</p>
</div>
) : (
<div className="space-y-4">
{/* Stats summary */}
<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-warning-amber mb-1">
<Clock className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Pending</span>
</div>
<div className="text-2xl font-bold text-white">
{protestsViewModel.protests.filter((p) => p.status === 'pending').length}
</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">
<CheckCircle className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Resolved</span>
</div>
<div className="text-2xl font-bold text-white">
{protestsViewModel.protests.filter((p) => p.status === 'upheld' || p.status === 'dismissed').length}
</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">
<Flag className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Total</span>
</div>
<div className="text-2xl font-bold text-white">
{protestsViewModel.protests.length}
</div>
</div>
</div>
{/* Protest list */}
<div className="space-y-3">
{protestsViewModel.protests.map((protest) => {
const race = protestsViewModel.racesById[protest.raceId];
const filer = protestsViewModel.driversById[protest.protestingDriverId];
const accused = protestsViewModel.driversById[protest.accusedDriverId];
const statusConfig = {
pending: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', border: 'border-warning-amber/30', icon: Clock, label: 'Pending Review' },
under_review: { color: 'text-primary-blue', bg: 'bg-primary-blue/10', border: 'border-primary-blue/30', icon: Flag, label: 'Under Review' },
awaiting_defense: { color: 'text-purple-400', bg: 'bg-purple-500/10', border: 'border-purple-500/30', icon: Clock, label: 'Awaiting Defense' },
upheld: { color: 'text-red-400', bg: 'bg-red-500/10', border: 'border-red-500/30', icon: AlertTriangle, label: 'Upheld' },
dismissed: { color: 'text-gray-400', bg: 'bg-gray-500/10', border: 'border-gray-500/30', icon: XCircle, label: 'Dismissed' },
withdrawn: { color: 'text-gray-500', bg: 'bg-gray-600/10', border: 'border-gray-600/30', icon: XCircle, label: 'Withdrawn' },
}[protest.status] ?? { color: 'text-gray-400', bg: 'bg-gray-500/10', border: 'border-gray-500/30', icon: Clock, label: protest.status };
const StatusIcon = statusConfig.icon;
return (
<div
key={protest.id}
className="rounded-lg border border-charcoal-outline bg-deep-graphite/70 p-4 hover:bg-iron-gray/30 transition-colors"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<div className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${statusConfig.bg} ${statusConfig.border} ${statusConfig.color} border`}>
<StatusIcon className="w-3 h-3" />
{statusConfig.label}
</div>
{race && (
<span className="text-xs text-gray-500 flex items-center gap-1">
<Calendar className="w-3 h-3" />
{race.track} {new Date(race.scheduledAt).toLocaleDateString()}
</span>
)}
</div>
<h3 className="text-sm font-semibold text-white mb-1 capitalize">
Incident at Lap {protest.incident.lap}
</h3>
<p className="text-xs text-gray-400 mb-3 line-clamp-2">
{protest.incident.description}
</p>
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5">
<User className="w-3 h-3 text-gray-500" />
<span className="text-gray-400">Filed by:</span>
<span className="text-white font-medium">{filer?.name ?? 'Unknown'}</span>
</div>
<div className="flex items-center gap-1.5">
<AlertTriangle className="w-3 h-3 text-warning-amber" />
<span className="text-gray-400">Against:</span>
<span className="text-warning-amber font-medium">{accused?.name ?? 'Unknown'}</span>
</div>
</div>
</div>
{protest.status === 'pending' && (
<div className="flex gap-2 shrink-0">
<Button variant="secondary" disabled>
Review
</Button>
</div>
)}
</div>
{protest.comment && (
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
<span className="text-xs text-gray-500">
Driver comment: "{protest.comment}"
</span>
</div>
)}
</div>
);
})}
</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">Alpha Note:</strong> Protest review and penalty application is demonstration-only.
In the full product, stewards can review evidence, apply penalties, and manage appeals.
</p>
</div>
</div>
)}
</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>
{configLoading && !configForm ? (
<div className="py-6 text-sm text-gray-400">Loading configuration</div>
) : configForm ? (
<div className="space-y-8">
<LeagueBasicsSection form={configForm} readOnly />
<LeagueStructureSection form={configForm} readOnly />
<LeagueTimingsSection form={configForm} readOnly />
<LeagueScoringSection form={configForm} presets={[]} readOnly />
<LeagueDropSection form={configForm} readOnly />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Season / Series
</label>
<p className="text-white">Alpha Demo Season</p>
</div>
<div className="rounded-lg border border-charcoal-outline bg-iron-gray/60 p-4 space-y-2">
<h3 className="text-sm font-semibold text-gray-200 mb-1">
At a glance
</h3>
<p className="text-xs text-gray-300">
<span className="font-semibold text-gray-200">Structure:</span>{' '}
{configForm.structure.mode === 'solo'
? `Solo • ${configForm.structure.maxDrivers} drivers`
: `Teams • ${configForm.structure.maxTeams ?? '—'} × ${
configForm.structure.driversPerTeam ?? '—'
} drivers (${configForm.structure.maxDrivers ?? '—'} total)`}
</p>
<p className="text-xs text-gray-300">
<span className="font-semibold text-gray-200">Schedule:</span>{' '}
{`${configForm.timings.roundsPlanned ?? '?'} rounds • ${
configForm.timings.qualifyingMinutes
} min Qualifying • ${configForm.timings.mainRaceMinutes} min Race`}
</p>
<p className="text-xs text-gray-300">
<span className="font-semibold text-gray-200">Scoring:</span>{' '}
{league.settings.pointsSystem.toUpperCase()}
</p>
</div>
</div>
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-300">League Owner</h3>
{ownerSummary ? (
<DriverSummaryPill
driver={ownerSummary.driver}
rating={ownerSummary.rating}
rank={ownerSummary.rank}
/>
) : (
<p className="text-sm text-gray-500">Loading owner details...</p>
)}
</div>
</div>
<div className="pt-4 border-t border-charcoal-outline">
<p className="text-sm text-gray-400">
League settings editing is alpha-only and changes are not persisted yet.
</p>
</div>
</div>
) : (
<div className="py-6 text-sm text-gray-500">
Unable to load league configuration for this demo league.
</div>
)}
</Card>
)}
<Modal
title="Reject join request"
description={
activeRejectRequest
? `Provide a reason for rejecting ${activeRejectRequest.driver?.name ?? 'this driver'}.`
: 'Provide a reason for rejecting this join request.'
}
primaryActionLabel="Reject"
secondaryActionLabel="Cancel"
onPrimaryAction={async () => {
const trimmed = rejectReason.trim();
if (!trimmed) {
setError('A rejection reason is required to reject a join request.');
return;
}
if (!activeRejectRequest) {
return;
}
await handleRejectRequest(activeRejectRequest.id, trimmed);
closeModal();
}}
onSecondaryAction={() => {
setRejectReason('');
}}
onOpenChange={(open) => {
if (!open) {
closeModal();
}
}}
isOpen={isRejectModalOpen}
>
<div className="space-y-3">
<p className="text-sm text-gray-300">
This will remove the join request and the driver will not be added to the league.
</p>
<div>
<label className="block text-sm font-medium text-gray-200 mb-1">
Rejection reason
</label>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
rows={4}
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-primary-blue"
placeholder="Let the driver know why this request was rejected..."
/>
</div>
</div>
</Modal>
</div>
);
}