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

View File

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

View File

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

View File

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

View File

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

View File

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