This commit is contained in:
2025-12-12 14:23:40 +01:00
parent 6a88fe93ab
commit 2cd3bfbb47
58 changed files with 2866 additions and 260 deletions

View File

@@ -21,6 +21,7 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading';
import LeagueReviewSummary from '@/components/leagues/LeagueReviewSummary';
import Input from '@/components/ui/Input';
import {
getListLeagueScoringPresetsQuery,
} from '@/lib/di-container';
@@ -52,7 +53,7 @@ import { LeagueStewardingSection } from './LeagueStewardingSection';
const STORAGE_KEY = 'gridpilot_league_wizard_draft';
const STORAGE_HIGHEST_STEP_KEY = 'gridpilot_league_wizard_highest_step';
function saveFormToStorage(form: LeagueConfigFormModel): void {
function saveFormToStorage(form: LeagueWizardFormModel): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
@@ -60,11 +61,16 @@ function saveFormToStorage(form: LeagueConfigFormModel): void {
}
}
function loadFormFromStorage(): LeagueConfigFormModel | null {
function loadFormFromStorage(): LeagueWizardFormModel | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return JSON.parse(stored) as LeagueConfigFormModel;
const parsed = JSON.parse(stored) as LeagueWizardFormModel;
if (!parsed.seasonName) {
const seasonStartDate = parsed.timings?.seasonStartDate;
parsed.seasonName = getDefaultSeasonName(seasonStartDate);
}
return parsed;
}
} catch {
// Ignore parse errors
@@ -105,6 +111,10 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6 | 7;
type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'stewarding' | 'review';
type LeagueWizardFormModel = LeagueConfigFormModel & {
seasonName?: string;
};
interface CreateLeagueWizardProps {
stepName: StepName;
onStepChange: (stepName: StepName) => void;
@@ -160,8 +170,21 @@ function getDefaultSeasonStartDate(): string {
return datePart ?? '';
}
function createDefaultForm(): LeagueConfigFormModel {
function getDefaultSeasonName(seasonStartDate?: string): string {
if (seasonStartDate) {
const parsed = new Date(seasonStartDate);
if (!Number.isNaN(parsed.getTime())) {
const year = parsed.getFullYear();
return `Season 1 (${year})`;
}
}
const fallbackYear = new Date().getFullYear();
return `Season 1 (${fallbackYear})`;
}
function createDefaultForm(): LeagueWizardFormModel {
const defaultPatternId = 'sprint-main-driver';
const defaultSeasonStartDate = getDefaultSeasonStartDate();
return {
basics: {
@@ -201,7 +224,7 @@ function createDefaultForm(): LeagueConfigFormModel {
recurrenceStrategy: 'weekly' as const,
raceStartTime: '20:00',
timezoneId: 'UTC',
seasonStartDate: getDefaultSeasonStartDate(),
seasonStartDate: defaultSeasonStartDate,
},
stewarding: {
decisionMode: 'admin_only',
@@ -214,6 +237,7 @@ function createDefaultForm(): LeagueConfigFormModel {
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
},
seasonName: getDefaultSeasonName(defaultSeasonStartDate),
};
}
@@ -229,7 +253,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
const [isHydrated, setIsHydrated] = useState(false);
// Initialize form from localStorage or defaults
const [form, setForm] = useState<LeagueConfigFormModel>(() =>
const [form, setForm] = useState<LeagueWizardFormModel>(() =>
createDefaultForm(),
);
@@ -405,20 +429,30 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
case 2:
return 'Will you compete for global rankings or race with friends?';
case 3:
return 'Will drivers compete individually or as part of teams?';
return 'Define how races in this season will run.';
case 4:
return 'Configure session durations and plan your season calendar.';
return 'Plan when this seasons races happen.';
case 5:
return 'Select a scoring preset, enable championships, and set drop rules.';
return 'Choose how points and drop scores work for this season.';
case 6:
return 'Configure how protests are handled and penalties decided.';
return 'Set how protests and stewarding work for this season.';
case 7:
return 'Everything looks good? Launch your new league!';
return 'Review your league and first season before launching.';
default:
return '';
}
};
const getStepContextLabel = (currentStep: Step): string => {
if (currentStep === 1 || currentStep === 2) {
return 'League setup';
}
if (currentStep >= 3 && currentStep <= 6) {
return 'Season setup';
}
return 'League & Season summary';
};
const currentStepData = steps.find((s) => s.id === step);
const CurrentStepIcon = currentStepData?.icon ?? FileText;
@@ -435,7 +469,10 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
Create a new league
</Heading>
<p className="text-sm text-gray-500">
Set up your racing series in {steps.length} easy steps
We'll also set up your first season in {steps.length} easy steps.
</p>
<p className="text-xs text-gray-500 mt-1">
A league is your long-term brand. Each season is a block of races you can run again and again.
</p>
</div>
</div>
@@ -557,7 +594,12 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
</div>
<div className="flex-1 min-w-0">
<Heading level={2} className="text-xl sm:text-2xl text-white leading-tight">
{getStepTitle(step)}
<div className="flex items-center gap-2 flex-wrap">
<span>{getStepTitle(step)}</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full border border-charcoal-outline bg-iron-gray/60 text-[11px] font-medium text-gray-300">
{getStepContextLabel(step)}
</span>
</div>
</Heading>
<p className="text-sm text-gray-400 mt-1">
{getStepSubtitle(step)}
@@ -575,15 +617,45 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
{/* Step content with min-height for consistency */}
<div className="min-h-[320px]">
{step === 1 && (
<div className="animate-fade-in">
<LeagueBasicsSection
form={form}
onChange={setForm}
errors={errors.basics ?? {}}
/>
</div>
)}
{step === 1 && (
<div className="animate-fade-in space-y-8">
<LeagueBasicsSection
form={form}
onChange={setForm}
errors={errors.basics ?? {}}
/>
<div className="rounded-xl border border-charcoal-outline bg-iron-gray/40 p-4">
<div className="flex items-center justify-between gap-2 mb-2">
<div>
<p className="text-xs font-semibold text-gray-300 uppercase tracking-wide">
First season
</p>
<p className="text-xs text-gray-500">
Name the first season that will run in this league.
</p>
</div>
</div>
<div className="space-y-2 mt-2">
<label className="text-sm font-medium text-gray-300">
Season name
</label>
<Input
value={form.seasonName ?? ''}
onChange={(e) =>
setForm((prev) => ({
...prev,
seasonName: e.target.value,
}))
}
placeholder="e.g., Season 1 (2025)"
/>
<p className="text-xs text-gray-500">
Seasons are the individual competitive runs inside your league. You can run Season 2, Season 3, or parallel seasons later.
</p>
</div>
</div>
</div>
)}
{step === 2 && (
<div className="animate-fade-in">
@@ -600,7 +672,15 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
)}
{step === 3 && (
<div className="animate-fade-in">
<div className="animate-fade-in space-y-4">
<div className="mb-2">
<p className="text-xs text-gray-500">
Applies to: First season of this league.
</p>
<p className="text-xs text-gray-500">
These settings only affect this season. Future seasons can use different formats.
</p>
</div>
<LeagueStructureSection
form={form}
onChange={setForm}
@@ -610,7 +690,15 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
)}
{step === 4 && (
<div className="animate-fade-in">
<div className="animate-fade-in space-y-4">
<div className="mb-2">
<p className="text-xs text-gray-500">
Applies to: First season of this league.
</p>
<p className="text-xs text-gray-500">
These settings only affect this season. Future seasons can use different formats.
</p>
</div>
<LeagueTimingsSection
form={form}
onChange={setForm}
@@ -621,6 +709,14 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
{step === 5 && (
<div className="animate-fade-in space-y-8">
<div className="mb-2">
<p className="text-xs text-gray-500">
Applies to: First season of this league.
</p>
<p className="text-xs text-gray-500">
These settings only affect this season. Future seasons can use different formats.
</p>
</div>
{/* Scoring Pattern Selection */}
<ScoringPatternSection
scoring={form.scoring}
@@ -658,7 +754,15 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
)}
{step === 6 && (
<div className="animate-fade-in">
<div className="animate-fade-in space-y-4">
<div className="mb-2">
<p className="text-xs text-gray-500">
Applies to: First season of this league.
</p>
<p className="text-xs text-gray-500">
These settings only affect this season. Future seasons can use different formats.
</p>
</div>
<LeagueStewardingSection
form={form}
onChange={setForm}
@@ -744,7 +848,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
{/* Helper text */}
<p className="text-center text-xs text-gray-500 mt-4">
You can edit all settings after creating your league
This will create your league and its first season. You can edit both later.
</p>
</form>
);

View File

@@ -15,9 +15,11 @@ import {
loadLeagueProtests,
removeLeagueMember as removeLeagueMemberCommand,
updateLeagueMemberRole as updateLeagueMemberRoleCommand,
loadLeagueSeasons,
type LeagueJoinRequestViewModel,
type LeagueOwnerSummaryViewModel,
type LeagueAdminProtestsViewModel,
type LeagueSeasonSummaryViewModel,
} from '@/lib/presenters/LeagueAdminPresenter';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import type { LeagueSummaryViewModel } from '@/lib/presenters/LeagueAdminPresenter';
@@ -51,13 +53,15 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
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 [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'seasons' | '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 [seasons, setSeasons] = useState<LeagueSeasonSummaryViewModel[]>([]);
const [seasonsLoading, setSeasonsLoading] = useState(false);
const loadJoinRequests = useCallback(async () => {
setLoading(true);
@@ -104,6 +108,22 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
loadConfig();
}, [league.id]);
useEffect(() => {
async function loadSeasonsVm() {
setSeasonsLoading(true);
try {
const items = await loadLeagueSeasons(league.id);
setSeasons(items);
} catch (err) {
console.error('Failed to load seasons:', err);
} finally {
setSeasonsLoading(false);
}
}
loadSeasonsVm();
}, [league.id]);
// Load protests for this league's races
useEffect(() => {
async function loadProtests() {
@@ -257,6 +277,16 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
>
Races
</button>
<button
onClick={() => setActiveTab('seasons')}
className={`pb-3 px-1 font-medium transition-colors whitespace-nowrap ${
activeTab === 'seasons'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Seasons
</button>
<button
onClick={() => setActiveTab('sponsorships')}
className={`pb-3 px-1 font-medium transition-colors whitespace-nowrap ${
@@ -429,6 +459,101 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
</Card>
)}
{activeTab === 'seasons' && (
<Card>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-semibold text-white">Seasons</h2>
<p className="text-sm text-gray-400 mt-1">
Plan, run, and review seasons inside this league.
</p>
</div>
</div>
{seasonsLoading ? (
<div className="text-center py-8 text-gray-400">Loading seasons</div>
) : seasons.length === 0 ? (
<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">
<Calendar className="w-6 h-6 text-gray-500" />
</div>
<p className="text-gray-400 text-sm">No seasons yet.</p>
<p className="text-gray-500 text-xs mt-1">
Your first season is created when you set up the league.
</p>
</div>
) : (
<div className="space-y-3">
{(() => {
const activeCount = seasons.filter((s) => s.status === 'active').length;
return seasons.map((season) => {
const start = season.startDate ? new Date(season.startDate) : null;
const end = season.endDate ? new Date(season.endDate) : null;
const hasParallel = activeCount > 1 && season.status === 'active';
const statusConfig: { label: string; className: string } = (() => {
switch (season.status) {
case 'planned':
return { label: 'Planned', className: 'bg-charcoal-outline/40 text-gray-300 border-charcoal-outline/60' };
case 'active':
return { label: 'Active', className: 'bg-performance-green/15 text-performance-green border-performance-green/40' };
case 'completed':
return { label: 'Completed', className: 'bg-primary-blue/15 text-primary-blue border-primary-blue/40' };
case 'archived':
return { label: 'Archived', className: 'bg-gray-600/20 text-gray-300 border-gray-600/40' };
case 'cancelled':
case 'canceled':
return { label: 'Cancelled', className: 'bg-red-500/10 text-red-400 border-red-500/40' };
default:
return { label: season.status, className: 'bg-charcoal-outline/40 text-gray-300 border-charcoal-outline/60' };
}
})();
return (
<div
key={season.seasonId}
className="rounded-lg border border-charcoal-outline bg-deep-graphite/70 p-4 flex items-start justify-between gap-4"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm font-semibold text-white truncate">
{season.name}
</h3>
{season.isPrimary && (
<span className="px-2 py-0.5 rounded-full text-[10px] font-medium bg-primary-blue/10 text-primary-blue border border-primary-blue/40">
Primary
</span>
)}
{hasParallel && (
<span className="px-2 py-0.5 rounded-full text-[10px] font-medium bg-warning-amber/10 text-warning-amber border border-warning-amber/40">
Parallel
</span>
)}
</div>
<p className="text-xs text-gray-500">
{start
? end
? `${start.toLocaleDateString()} ${end.toLocaleDateString()}`
: `Starts ${start.toLocaleDateString()}`
: 'No dates configured yet'}
</p>
</div>
<div className="flex flex-col items-end gap-1">
<span
className={`px-2 py-0.5 rounded-full text-[10px] font-medium border ${statusConfig.className}`}
>
{statusConfig.label}
</span>
</div>
</div>
);
});
})()}
</div>
)}
</Card>
)}
{activeTab === 'protests' && (
<Card>
<div className="flex items-center justify-between mb-6">

View File

@@ -101,7 +101,8 @@ function FeatureBadge({
}
export default function LeagueReviewSummary({ form, presets }: LeagueReviewSummaryProps) {
const { basics, structure, timings, scoring, championships, dropPolicy } = form;
const { basics, structure, timings, scoring, championships, dropPolicy, stewarding } = form;
const seasonName = (form as LeagueConfigFormModel & { seasonName?: string }).seasonName;
const modeLabel =
structure.mode === 'solo'
@@ -147,10 +148,30 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
}
return { emoji: '✓', label: 'All count', description: 'Every race counts' };
};
const dropRuleInfo = getDropRuleInfo();
const preset = presets.find((p) => p.id === scoring.patternId) ?? null;
const seasonStartLabel =
timings.seasonStartDate
? new Date(timings.seasonStartDate).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
: null;
const stewardingLabel = (() => {
switch (stewarding.decisionMode) {
case 'admin_only':
return 'Admin-only decisions';
case 'steward_vote':
return 'Steward panel voting';
default:
return stewarding.decisionMode;
}
})();
const dropRuleInfo = getDropRuleInfo();
const preset = presets.find((p) => p.id === scoring.patternId) ?? null;
const getScoringEmoji = () => {
if (!preset) return '🏁';
@@ -173,91 +194,115 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
(timings.qualifyingMinutes ?? 0) +
(timings.sprintRaceMinutes ?? 0) +
(timings.mainRaceMinutes ?? 0);
return (
<div className="space-y-6">
{/* Hero Banner */}
<div className="relative rounded-2xl bg-gradient-to-br from-primary-blue/20 via-iron-gray to-iron-gray border border-primary-blue/30 p-6 overflow-hidden">
{/* Background decoration */}
<div className="absolute top-0 right-0 w-32 h-32 bg-primary-blue/10 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-24 h-24 bg-neon-aqua/5 rounded-full blur-2xl" />
<div className="relative flex items-start gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-blue/20 border border-primary-blue/30 shrink-0">
<Rocket className="w-7 h-7 text-primary-blue" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-xl font-bold text-white mb-1 truncate">
{basics.name || 'Your New League'}
</h2>
<p className="text-sm text-gray-400 mb-3">
{basics.description || 'Ready to launch your racing series!'}
</p>
<div className="flex flex-wrap items-center gap-3">
{/* Ranked/Unranked Badge */}
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium ${
isRanked
? 'bg-primary-blue/15 text-primary-blue border border-primary-blue/30'
: 'bg-neon-aqua/15 text-neon-aqua border border-neon-aqua/30'
}`}>
{isRanked ? <Trophy className="w-3 h-3" /> : <Users className="w-3 h-3" />}
<span className="font-semibold">{visibilityLabel}</span>
<span className="text-[10px] opacity-70"> {visibilityDescription}</span>
</span>
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
<Gamepad2 className="w-3 h-3" />
iRacing
</span>
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
{structure.mode === 'solo' ? <User className="w-3 h-3" /> : <UsersRound className="w-3 h-3" />}
{modeLabel}
</span>
{/* League Summary */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-300">League summary</h3>
<div className="relative rounded-2xl bg-gradient-to-br from-primary-blue/20 via-iron-gray to-iron-gray border border-primary-blue/30 p-6 overflow-hidden">
{/* Background decoration */}
<div className="absolute top-0 right-0 w-32 h-32 bg-primary-blue/10 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-24 h-24 bg-neon-aqua/5 rounded-full blur-2xl" />
<div className="relative flex items-start gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-blue/20 border border-primary-blue/30 shrink-0">
<Rocket className="w-7 h-7 text-primary-blue" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-xl font-bold text-white mb-1 truncate">
{basics.name || 'Your New League'}
</h2>
<p className="text-sm text-gray-400 mb-3">
{basics.description || 'Ready to launch your racing series!'}
</p>
<div className="flex flex-wrap items-center gap-3">
{/* Ranked/Unranked Badge */}
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium ${
isRanked
? 'bg-primary-blue/15 text-primary-blue border border-primary-blue/30'
: 'bg-neon-aqua/15 text-neon-aqua border border-neon-aqua/30'
}`}>
{isRanked ? <Trophy className="w-3 h-3" /> : <Users className="w-3 h-3" />}
<span className="font-semibold">{visibilityLabel}</span>
<span className="text-[10px] opacity-70"> {visibilityDescription}</span>
</span>
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
<Gamepad2 className="w-3 h-3" />
iRacing
</span>
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
{structure.mode === 'solo' ? <User className="w-3 h-3" /> : <UsersRound className="w-3 h-3" />}
{modeLabel}
</span>
</div>
</div>
</div>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Capacity */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10 mx-auto mb-2">
<Users className="w-5 h-5 text-primary-blue" />
</div>
<div className="text-2xl font-bold text-white">{capacityValue}</div>
<div className="text-xs text-gray-500">{capacityLabel}</div>
{/* Season Summary */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-300">First season summary</h3>
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-400">
<span>{seasonName || 'First season of this league'}</span>
{seasonStartLabel && (
<>
<span></span>
<span>Starts {seasonStartLabel}</span>
</>
)}
{typeof timings.roundsPlanned === 'number' && (
<>
<span></span>
<span>{timings.roundsPlanned} rounds planned</span>
</>
)}
<span></span>
<span>Stewarding: {stewardingLabel}</span>
</div>
{/* Rounds */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10 mx-auto mb-2">
<Flag className="w-5 h-5 text-performance-green" />
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Capacity */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10 mx-auto mb-2">
<Users className="w-5 h-5 text-primary-blue" />
</div>
<div className="text-2xl font-bold text-white">{capacityValue}</div>
<div className="text-xs text-gray-500">{capacityLabel}</div>
</div>
<div className="text-2xl font-bold text-white">{timings.roundsPlanned ?? '—'}</div>
<div className="text-xs text-gray-500">rounds</div>
</div>
{/* Weekend Duration */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/10 mx-auto mb-2">
<Timer className="w-5 h-5 text-warning-amber" />
{/* Rounds */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10 mx-auto mb-2">
<Flag className="w-5 h-5 text-performance-green" />
</div>
<div className="text-2xl font-bold text-white">{timings.roundsPlanned ?? '—'}</div>
<div className="text-xs text-gray-500">rounds</div>
</div>
<div className="text-2xl font-bold text-white">{totalWeekendMinutes > 0 ? `${totalWeekendMinutes}` : '—'}</div>
<div className="text-xs text-gray-500">min/weekend</div>
</div>
{/* Championships */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-aqua/10 mx-auto mb-2">
<Award className="w-5 h-5 text-neon-aqua" />
{/* Weekend Duration */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/10 mx-auto mb-2">
<Timer className="w-5 h-5 text-warning-amber" />
</div>
<div className="text-2xl font-bold text-white">{totalWeekendMinutes > 0 ? `${totalWeekendMinutes}` : '—'}</div>
<div className="text-xs text-gray-500">min/weekend</div>
</div>
<div className="text-2xl font-bold text-white">
{[championships.enableDriverChampionship, championships.enableTeamChampionship, championships.enableNationsChampionship, championships.enableTrophyChampionship].filter(Boolean).length}
{/* Championships */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-aqua/10 mx-auto mb-2">
<Award className="w-5 h-5 text-neon-aqua" />
</div>
<div className="text-2xl font-bold text-white">
{[championships.enableDriverChampionship, championships.enableTeamChampionship, championships.enableNationsChampionship, championships.enableTrophyChampionship].filter(Boolean).length}
</div>
<div className="text-xs text-gray-500">championships</div>
</div>
<div className="text-xs text-gray-500">championships</div>
</div>
</div>
{/* Detail Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Schedule Card */}
@@ -273,7 +318,7 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
<InfoRow icon={Flag} label="Main Race" value={formatMinutes(timings.mainRaceMinutes)} />
</div>
</ReviewCard>
{/* Scoring Card */}
<ReviewCard icon={Trophy} iconColor="text-warning-amber" bgColor="bg-warning-amber/10" title="Scoring System">
<div className="space-y-3">
@@ -288,7 +333,7 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
<span className="px-2 py-0.5 rounded bg-primary-blue/20 text-[10px] font-medium text-primary-blue">Custom</span>
)}
</div>
{/* Drop Rule */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-charcoal-outline/50">
@@ -302,7 +347,7 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
</div>
</ReviewCard>
</div>
{/* Championships Section */}
<ReviewCard icon={Award} iconColor="text-neon-aqua" bgColor="bg-neon-aqua/10" title="Active Championships">
<div className="flex flex-wrap gap-2">
@@ -339,7 +384,7 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
)}
</div>
</ReviewCard>
{/* Ready to launch message */}
<div className="rounded-xl bg-performance-green/5 border border-performance-green/20 p-4">
<div className="flex items-center gap-3">

View File

@@ -167,7 +167,10 @@ export function LeagueSponsorshipsSection({
<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
Define pricing for sponsor slots in this league. Sponsors pay per season.
</p>
<p className="text-xs text-gray-500 mt-1">
These sponsors are attached to seasons in this league, so you can change partners from season to season.
</p>
</div>
{!readOnly && (

View File

@@ -79,7 +79,7 @@ export default function PendingSponsorshipRequests({
</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.
When sponsors apply to sponsor this {entityType}, their requests will appear here. Sponsorships are attached to seasons, so you can change partners from season to season.
</p>
</div>
);
@@ -233,7 +233,7 @@ export default function PendingSponsorshipRequests({
<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.
The sponsor will be charged per season and you'll receive the payment minus 10% platform fee.
</p>
</div>
</div>

View File

@@ -127,6 +127,13 @@ function getEntityIcon(type: EntityType) {
}
}
function getSponsorshipTagline(type: EntityType): string {
if (type === 'league') {
return 'Reach engaged sim racers by sponsoring a season in this league.';
}
return `Reach engaged sim racers by sponsoring this ${getEntityLabel(type).toLowerCase()}`;
}
// ============================================================================
// COMPONENT
// ============================================================================
@@ -227,7 +234,7 @@ export default function SponsorInsightsCard({
<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}
{getSponsorshipTagline(entityType)}
</p>
</div>
<div className="flex items-center gap-2">
@@ -412,7 +419,7 @@ export default function SponsorInsightsCard({
{/* 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
10% platform fee applies Logos burned on all liveries Sponsorships are attached to seasons, so you can change partners from season to season
{appliedTiers.size > 0 && ' • Application pending review'}
</p>
<Button

View File

@@ -113,6 +113,7 @@ import {
AcceptSponsorshipRequestUseCase,
RejectSponsorshipRequestUseCase,
} from '@gridpilot/racing/application';
import { ListSeasonsForLeagueUseCase } from '@gridpilot/racing/application/use-cases/SeasonUseCases';
import { GetDashboardOverviewUseCase } from '@gridpilot/racing/application/use-cases/GetDashboardOverviewUseCase';
import { GetProfileOverviewUseCase } from '@gridpilot/racing/application/use-cases/GetProfileOverviewUseCase';
import { UpdateDriverProfileUseCase } from '@gridpilot/racing/application/use-cases/UpdateDriverProfileUseCase';
@@ -1007,6 +1008,14 @@ export function configureDIContainer(): void {
)
);
container.registerInstance(
DI_TOKENS.ListSeasonsForLeagueUseCase,
new ListSeasonsForLeagueUseCase(
leagueRepository,
seasonRepository,
)
);
const leagueScoringPresetsPresenter = new LeagueScoringPresetsPresenter();
container.registerInstance(
DI_TOKENS.ListLeagueScoringPresetsUseCase,
@@ -1349,7 +1358,11 @@ export function configureDIContainer(): void {
container.registerInstance(
DI_TOKENS.AcceptSponsorshipRequestUseCase,
new AcceptSponsorshipRequestUseCase(sponsorshipRequestRepository, seasonSponsorshipRepository)
new AcceptSponsorshipRequestUseCase(
sponsorshipRequestRepository,
seasonSponsorshipRepository,
seasonRepository,
)
);
container.registerInstance(

View File

@@ -100,6 +100,7 @@ import type { PreviewLeagueScheduleUseCase } from '@gridpilot/racing/application
import type { LeagueScoringPresetProvider } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
import type { GetDashboardOverviewUseCase } from '@gridpilot/racing/application/use-cases/GetDashboardOverviewUseCase';
import type { ListSeasonsForLeagueUseCase } from '@gridpilot/racing/application/use-cases/SeasonUseCases';
import { createDemoDriverStats, getDemoLeagueRankings, type DriverStats } from '@gridpilot/testing-support';
/**
@@ -254,6 +255,11 @@ class DIContainer {
return getDIContainer().resolve<GetAllLeaguesWithCapacityAndScoringUseCase>(DI_TOKENS.GetAllLeaguesWithCapacityAndScoringUseCase);
}
get listSeasonsForLeagueUseCase(): ListSeasonsForLeagueUseCase {
this.ensureInitialized();
return getDIContainer().resolve<ListSeasonsForLeagueUseCase>(DI_TOKENS.ListSeasonsForLeagueUseCase);
}
get listLeagueScoringPresetsUseCase(): ListLeagueScoringPresetsUseCase {
this.ensureInitialized();
return getDIContainer().resolve<ListLeagueScoringPresetsUseCase>(DI_TOKENS.ListLeagueScoringPresetsUseCase);
@@ -650,6 +656,10 @@ export function getGetAllLeaguesWithCapacityAndScoringUseCase(): GetAllLeaguesWi
return DIContainer.getInstance().getAllLeaguesWithCapacityAndScoringUseCase;
}
export function getListSeasonsForLeagueUseCase(): ListSeasonsForLeagueUseCase {
return DIContainer.getInstance().listSeasonsForLeagueUseCase;
}
export function getGetLeagueScoringConfigUseCase(): GetLeagueScoringConfigUseCase {
return DIContainer.getInstance().getLeagueScoringConfigUseCase;
}

View File

@@ -82,6 +82,7 @@ export const DI_TOKENS = {
PreviewLeagueScheduleUseCase: Symbol.for('PreviewLeagueScheduleUseCase'),
GetRaceWithSOFUseCase: Symbol.for('GetRaceWithSOFUseCase'),
GetLeagueStatsUseCase: Symbol.for('GetLeagueStatsUseCase'),
ListSeasonsForLeagueUseCase: Symbol.for('ListSeasonsForLeagueUseCase'),
GetRacesPageDataUseCase: Symbol.for('GetRacesPageDataUseCase'),
GetAllRacesPageDataUseCase: Symbol.for('GetAllRacesPageDataUseCase'),
GetRaceDetailUseCase: Symbol.for('GetRaceDetailUseCase'),

View File

@@ -15,6 +15,7 @@ import {
getProtestRepository,
getDriverStats,
getAllDriverRankings,
getListSeasonsForLeagueUseCase,
} from '@/lib/di-container';
export interface LeagueJoinRequestViewModel {
@@ -63,6 +64,16 @@ export interface LeagueAdminPermissionsViewModel {
canUpdateRoles: boolean;
}
export interface LeagueSeasonSummaryViewModel {
seasonId: string;
name: string;
status: string;
startDate?: Date;
endDate?: Date;
isPrimary: boolean;
isParallelActive: boolean;
}
export interface LeagueAdminViewModel {
joinRequests: LeagueJoinRequestViewModel[];
ownerSummary: LeagueOwnerSummaryViewModel | null;
@@ -356,4 +367,20 @@ export async function loadLeagueProtests(leagueId: string): Promise<LeagueAdminP
racesById,
driversById,
};
}
export async function loadLeagueSeasons(leagueId: string): Promise<LeagueSeasonSummaryViewModel[]> {
const useCase = getListSeasonsForLeagueUseCase();
const result = await useCase.execute({ leagueId });
const activeCount = result.items.filter((s) => s.status === 'active').length;
return result.items.map((s) => ({
seasonId: s.seasonId,
name: s.name,
status: s.status,
...(s.startDate ? { startDate: s.startDate } : {}),
...(s.endDate ? { endDate: s.endDate } : {}),
isPrimary: s.isPrimary ?? false,
isParallelActive: activeCount > 1 && s.status === 'active',
}));
}