wip
This commit is contained in:
@@ -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 season’s 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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user