This commit is contained in:
2025-12-05 12:24:38 +01:00
parent fb509607c1
commit 5a9cd28d5b
47 changed files with 5456 additions and 228 deletions

View File

@@ -0,0 +1,94 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import {
type LeagueScheduleDTO,
type LeagueSchedulePreviewDTO,
} from '@gridpilot/racing/application';
import { getPreviewLeagueScheduleQuery } from '@/lib/di-container';
interface RequestBody {
seasonStartDate?: string;
raceStartTime?: string;
timezoneId?: string;
recurrenceStrategy?: LeagueScheduleDTO['recurrenceStrategy'];
intervalWeeks?: number;
weekdays?: LeagueScheduleDTO['weekdays'];
monthlyOrdinal?: LeagueScheduleDTO['monthlyOrdinal'];
monthlyWeekday?: LeagueScheduleDTO['monthlyWeekday'];
plannedRounds?: number;
}
function toLeagueScheduleDTO(body: RequestBody): LeagueScheduleDTO {
const {
seasonStartDate,
raceStartTime,
timezoneId,
recurrenceStrategy,
intervalWeeks,
weekdays,
monthlyOrdinal,
monthlyWeekday,
plannedRounds,
} = body;
if (
!seasonStartDate ||
!raceStartTime ||
!timezoneId ||
!recurrenceStrategy ||
plannedRounds == null
) {
throw new Error(
'seasonStartDate, raceStartTime, timezoneId, recurrenceStrategy, and plannedRounds are required',
);
}
const dto: LeagueScheduleDTO = {
seasonStartDate,
raceStartTime,
timezoneId,
recurrenceStrategy,
plannedRounds,
};
if (intervalWeeks != null) {
dto.intervalWeeks = intervalWeeks;
}
if (weekdays && weekdays.length > 0) {
dto.weekdays = weekdays;
}
if (monthlyOrdinal != null) {
dto.monthlyOrdinal = monthlyOrdinal;
}
if (monthlyWeekday != null) {
dto.monthlyWeekday = monthlyWeekday;
}
return dto;
}
export async function POST(request: NextRequest) {
try {
const json = (await request.json()) as RequestBody;
const schedule = toLeagueScheduleDTO(json);
const query = getPreviewLeagueScheduleQuery();
const preview: LeagueSchedulePreviewDTO = await query.execute({
schedule,
maxRounds: 10,
});
return NextResponse.json(preview, { status: 200 });
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to preview schedule';
return NextResponse.json(
{
error: message,
},
{ status: 400 },
);
}
}

View File

@@ -9,18 +9,21 @@ import LeagueMembers from '@/components/leagues/LeagueMembers';
import LeagueSchedule from '@/components/leagues/LeagueSchedule';
import LeagueAdmin from '@/components/leagues/LeagueAdmin';
import StandingsTable from '@/components/leagues/StandingsTable';
import LeagueScoringTab from '@/components/leagues/LeagueScoringTab';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
import { League } from '@gridpilot/racing/domain/entities/League';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import type { LeagueDriverSeasonStatsDTO } from '@gridpilot/racing/application/dto/LeagueDriverSeasonStatsDTO';
import type { LeagueScoringConfigDTO } from '@gridpilot/racing/application/dto/LeagueScoringConfigDTO';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import {
getLeagueRepository,
getRaceRepository,
getDriverRepository,
getGetLeagueDriverSeasonStatsQuery,
getGetLeagueScoringConfigQuery,
getDriverStats,
getAllDriverRankings,
} from '@/lib/di-container';
@@ -36,9 +39,12 @@ export default function LeagueDetailPage() {
const [owner, setOwner] = useState<Driver | null>(null);
const [standings, setStandings] = useState<LeagueDriverSeasonStatsDTO[]>([]);
const [drivers, setDrivers] = useState<DriverDTO[]>([]);
const [scoringConfig, setScoringConfig] = useState<LeagueScoringConfigDTO | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'overview' | 'schedule' | 'standings' | 'admin'>('overview');
const [activeTab, setActiveTab] = useState<
'overview' | 'schedule' | 'standings' | 'scoring' | 'admin'
>('overview');
const [refreshKey, setRefreshKey] = useState(0);
const currentDriverId = useEffectiveDriverId();
@@ -71,6 +77,11 @@ export default function LeagueDetailPage() {
const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId });
setStandings(leagueStandings);
// Load scoring configuration for the active season
const getLeagueScoringConfigQuery = getGetLeagueScoringConfigQuery();
const scoring = await getLeagueScoringConfigQuery.execute({ leagueId });
setScoringConfig(scoring);
// Load all drivers for standings and map to DTOs for UI components
const allDrivers = await driverRepo.findAll();
const driverDtos: DriverDTO[] = allDrivers
@@ -100,9 +111,10 @@ export default function LeagueDetailPage() {
initialTab === 'overview' ||
initialTab === 'schedule' ||
initialTab === 'standings' ||
initialTab === 'scoring' ||
initialTab === 'admin'
) {
setActiveTab(initialTab);
setActiveTab(initialTab as typeof activeTab);
}
}, [searchParams]);
@@ -231,6 +243,16 @@ export default function LeagueDetailPage() {
>
Standings
</button>
<button
onClick={() => setActiveTab('scoring')}
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
activeTab === 'scoring'
? 'bg-primary-blue text-white'
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
}`}
>
Scoring
</button>
{isAdmin && (
<button
onClick={() => setActiveTab('admin')}
@@ -266,22 +288,36 @@ export default function LeagueDetailPage() {
</div>
<div className="pt-4 border-t border-charcoal-outline">
<h3 className="text-white font-medium mb-3">League Settings</h3>
<h3 className="text-white font-medium mb-3">At a glance</h3>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<label className="text-sm text-gray-500">Points System</label>
<p className="text-white">{league.settings.pointsSystem.toUpperCase()}</p>
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
Structure
</h4>
<p className="text-gray-200">
Solo {league.settings.maxDrivers ?? 32} drivers
</p>
</div>
<div>
<label className="text-sm text-gray-500">Session Duration</label>
<p className="text-white">{league.settings.sessionDuration} minutes</p>
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
Schedule
</h4>
<p className="text-gray-200">
{`? rounds • 30 min Qualifying • ${
typeof league.settings.sessionDuration === 'number'
? league.settings.sessionDuration
: 40
} min Races`}
</p>
</div>
<div>
<label className="text-sm text-gray-500">Qualifying Format</label>
<p className="text-white capitalize">{league.settings.qualifyingFormat}</p>
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
Scoring & drops
</h4>
<p className="text-gray-200">
{league.settings.pointsSystem.toUpperCase()}
</p>
</div>
</div>
</div>
@@ -439,6 +475,23 @@ export default function LeagueDetailPage() {
</Card>
)}
{activeTab === 'scoring' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Scoring</h2>
<LeagueScoringTab
scoringConfig={scoringConfig}
practiceMinutes={20}
qualifyingMinutes={30}
sprintRaceMinutes={20}
mainRaceMinutes={
typeof league.settings.sessionDuration === 'number'
? league.settings.sessionDuration
: 40
}
/>
</Card>
)}
{activeTab === 'admin' && isAdmin && (
<LeagueAdmin
league={league}

View File

@@ -0,0 +1,15 @@
'use client';
import CreateLeagueWizard from '@/components/leagues/CreateLeagueWizard';
import Section from '@/components/ui/Section';
import Container from '@/components/ui/Container';
export default function CreateLeaguePage() {
return (
<Section>
<Container size="md">
<CreateLeagueWizard />
</Container>
</Section>
);
}

View File

@@ -3,18 +3,16 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import LeagueCard from '@/components/leagues/LeagueCard';
import CreateLeagueForm from '@/components/leagues/CreateLeagueForm';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Input from '@/components/ui/Input';
import type { LeagueDTO } from '@gridpilot/racing/application/dto/LeagueDTO';
import { getGetAllLeaguesWithCapacityQuery } from '@/lib/di-container';
import type { LeagueSummaryDTO } from '@gridpilot/racing/application/dto/LeagueSummaryDTO';
import { getGetAllLeaguesWithCapacityAndScoringQuery } from '@/lib/di-container';
export default function LeaguesPage() {
const router = useRouter();
const [leagues, setLeagues] = useState<LeagueDTO[]>([]);
const [leagues, setLeagues] = useState<LeagueSummaryDTO[]>([]);
const [loading, setLoading] = useState(true);
const [showCreateForm, setShowCreateForm] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('name');
@@ -24,7 +22,7 @@ export default function LeaguesPage() {
const loadLeagues = async () => {
try {
const query = getGetAllLeaguesWithCapacityQuery();
const query = getGetAllLeaguesWithCapacityAndScoringQuery();
const allLeagues = await query.execute();
setLeagues(allLeagues);
} catch (error) {
@@ -78,24 +76,12 @@ export default function LeaguesPage() {
<Button
variant="primary"
onClick={() => setShowCreateForm(!showCreateForm)}
onClick={() => router.push('/leagues/create')}
>
{showCreateForm ? 'Cancel' : 'Create League'}
Create League
</Button>
</div>
{showCreateForm && (
<Card className="mb-8 max-w-2xl mx-auto">
<div className="mb-6">
<h2 className="text-xl font-semibold text-white mb-2">Create New League</h2>
<p className="text-gray-400 text-sm">
Experiment with different point systems
</p>
</div>
<CreateLeagueForm />
</Card>
)}
{leagues.length > 0 && (
<Card className="mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">

View File

@@ -0,0 +1,587 @@
'use client';
import { useEffect, useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading';
import LeagueReviewSummary from './LeagueReviewSummary';
import {
getDriverRepository,
getListLeagueScoringPresetsQuery,
getCreateLeagueWithSeasonAndScoringUseCase,
} from '@/lib/di-container';
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import { LeagueBasicsSection } from './LeagueBasicsSection';
import { LeagueStructureSection } from './LeagueStructureSection';
import {
LeagueScoringSection,
ScoringPatternSection,
ChampionshipsSection,
} from './LeagueScoringSection';
import { LeagueDropSection } from './LeagueDropSection';
import { LeagueTimingsSection } from './LeagueTimingsSection';
type Step = 1 | 2 | 3 | 4 | 5 | 6;
interface WizardErrors {
basics?: {
name?: string;
visibility?: string;
};
structure?: {
maxDrivers?: string;
maxTeams?: string;
driversPerTeam?: string;
};
timings?: {
qualifyingMinutes?: string;
mainRaceMinutes?: string;
roundsPlanned?: string;
};
scoring?: {
patternId?: string;
};
submit?: string;
}
function createDefaultForm(): LeagueConfigFormModel {
const defaultPatternId = 'sprint-main-driver';
return {
basics: {
name: '',
description: '',
visibility: 'public',
gameId: 'iracing',
},
structure: {
mode: 'solo',
maxDrivers: 24,
maxTeams: undefined,
driversPerTeam: undefined,
multiClassEnabled: false,
},
championships: {
enableDriverChampionship: true,
enableTeamChampionship: false,
enableNationsChampionship: false,
enableTrophyChampionship: false,
},
scoring: {
patternId: defaultPatternId,
customScoringEnabled: false,
},
dropPolicy: {
strategy: 'bestNResults',
n: 6,
},
timings: {
practiceMinutes: 20,
qualifyingMinutes: 30,
sprintRaceMinutes: defaultPatternId === 'sprint-main-driver' ? 20 : undefined,
mainRaceMinutes: 40,
sessionCount: 2,
roundsPlanned: 8,
},
};
}
export default function CreateLeagueWizard() {
const router = useRouter();
const [step, setStep] = useState<Step>(1);
const [loading, setLoading] = useState(false);
const [presetsLoading, setPresetsLoading] = useState(true);
const [presets, setPresets] = useState<LeagueScoringPresetDTO[]>([]);
const [errors, setErrors] = useState<WizardErrors>({});
const [form, setForm] = useState<LeagueConfigFormModel>(() =>
createDefaultForm(),
);
/**
* Local-only weekend template selection for Step 3.
* This does not touch domain models; it only seeds timing defaults.
*/
const [weekendTemplate, setWeekendTemplate] = useState<string>('');
useEffect(() => {
async function loadPresets() {
try {
const query = getListLeagueScoringPresetsQuery();
const result = await query.execute();
setPresets(result);
if (result.length > 0) {
setForm((prev) => ({
...prev,
scoring: {
...prev.scoring,
patternId: prev.scoring.patternId || result[0].id,
customScoringEnabled: prev.scoring.customScoringEnabled ?? false,
},
}));
}
} catch (err) {
setErrors((prev) => ({
...prev,
submit:
err instanceof Error
? err.message
: 'Failed to load scoring presets',
}));
} finally {
setPresetsLoading(false);
}
}
loadPresets();
}, []);
const validateStep = (currentStep: Step): boolean => {
const nextErrors: WizardErrors = {};
if (currentStep === 1) {
const basicsErrors: WizardErrors['basics'] = {};
if (!form.basics.name.trim()) {
basicsErrors.name = 'Name is required';
}
if (!form.basics.visibility) {
basicsErrors.visibility = 'Visibility is required';
}
if (Object.keys(basicsErrors).length > 0) {
nextErrors.basics = basicsErrors;
}
}
if (currentStep === 2) {
const structureErrors: WizardErrors['structure'] = {};
if (form.structure.mode === 'solo') {
if (!form.structure.maxDrivers || form.structure.maxDrivers <= 0) {
structureErrors.maxDrivers =
'Max drivers must be greater than 0 for solo leagues';
}
} else if (form.structure.mode === 'fixedTeams') {
if (
!form.structure.maxTeams ||
form.structure.maxTeams <= 0
) {
structureErrors.maxTeams =
'Max teams must be greater than 0 for team leagues';
}
if (
!form.structure.driversPerTeam ||
form.structure.driversPerTeam <= 0
) {
structureErrors.driversPerTeam =
'Drivers per team must be greater than 0';
}
}
if (Object.keys(structureErrors).length > 0) {
nextErrors.structure = structureErrors;
}
}
if (currentStep === 3) {
const timingsErrors: WizardErrors['timings'] = {};
if (!form.timings.qualifyingMinutes || form.timings.qualifyingMinutes <= 0) {
timingsErrors.qualifyingMinutes =
'Qualifying duration must be greater than 0 minutes';
}
if (!form.timings.mainRaceMinutes || form.timings.mainRaceMinutes <= 0) {
timingsErrors.mainRaceMinutes =
'Main race duration must be greater than 0 minutes';
}
if (Object.keys(timingsErrors).length > 0) {
nextErrors.timings = timingsErrors;
}
}
if (currentStep === 4) {
const scoringErrors: WizardErrors['scoring'] = {};
if (!form.scoring.patternId && !form.scoring.customScoringEnabled) {
scoringErrors.patternId =
'Select a scoring preset or enable custom scoring';
}
if (Object.keys(scoringErrors).length > 0) {
nextErrors.scoring = scoringErrors;
}
}
setErrors((prev) => ({
...prev,
...nextErrors,
}));
return Object.keys(nextErrors).length === 0;
};
const goToNextStep = () => {
if (!validateStep(step)) {
return;
}
setStep((prev) => (prev < 6 ? ((prev + 1) as Step) : prev));
};
const goToPreviousStep = () => {
setStep((prev) => (prev > 1 ? ((prev - 1) as Step) : prev));
};
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
if (loading) return;
if (
!validateStep(1) ||
!validateStep(2) ||
!validateStep(3) ||
!validateStep(4) ||
!validateStep(5)
) {
setStep(1);
return;
}
setLoading(true);
setErrors((prev) => ({ ...prev, submit: undefined }));
try {
const driverRepo = getDriverRepository();
const drivers = await driverRepo.findAll();
const currentDriver = drivers[0];
if (!currentDriver) {
setErrors((prev) => ({
...prev,
submit:
'No driver profile found. Please create a driver profile first.',
}));
setLoading(false);
return;
}
const createUseCase = getCreateLeagueWithSeasonAndScoringUseCase();
const structure = form.structure;
let maxDrivers: number | undefined;
let maxTeams: number | undefined;
if (structure.mode === 'solo') {
maxDrivers =
typeof structure.maxDrivers === 'number'
? structure.maxDrivers
: undefined;
maxTeams = undefined;
} else {
const teams =
typeof structure.maxTeams === 'number' ? structure.maxTeams : 0;
const perTeam =
typeof structure.driversPerTeam === 'number'
? structure.driversPerTeam
: 0;
maxTeams = teams > 0 ? teams : undefined;
maxDrivers =
teams > 0 && perTeam > 0 ? teams * perTeam : undefined;
}
const command = {
name: form.basics.name.trim(),
description: form.basics.description?.trim() || undefined,
visibility: form.basics.visibility,
ownerId: currentDriver.id,
gameId: form.basics.gameId,
maxDrivers,
maxTeams,
enableDriverChampionship: form.championships.enableDriverChampionship,
enableTeamChampionship: form.championships.enableTeamChampionship,
enableNationsChampionship:
form.championships.enableNationsChampionship,
enableTrophyChampionship:
form.championships.enableTrophyChampionship,
scoringPresetId: form.scoring.patternId || undefined,
} as const;
const result = await createUseCase.execute(command);
router.push(`/leagues/${result.leagueId}`);
} catch (err) {
setErrors((prev) => ({
...prev,
submit:
err instanceof Error ? err.message : 'Failed to create league',
}));
setLoading(false);
}
};
const currentPreset =
presets.find((p) => p.id === form.scoring.patternId) ?? null;
const handleWeekendTemplateChange = (template: string) => {
setWeekendTemplate(template);
setForm((prev) => {
const timings = prev.timings ?? {};
if (template === 'feature') {
return {
...prev,
timings: {
...timings,
practiceMinutes: 20,
qualifyingMinutes: 30,
sprintRaceMinutes: undefined,
mainRaceMinutes: 40,
sessionCount: 1,
},
};
}
if (template === 'sprintFeature') {
return {
...prev,
timings: {
...timings,
practiceMinutes: 15,
qualifyingMinutes: 20,
sprintRaceMinutes: 20,
mainRaceMinutes: 35,
sessionCount: 2,
},
};
}
if (template === 'endurance') {
return {
...prev,
timings: {
...timings,
practiceMinutes: 30,
qualifyingMinutes: 30,
sprintRaceMinutes: undefined,
mainRaceMinutes: 90,
sessionCount: 1,
},
};
}
return prev;
});
};
const steps = [
{ id: 1 as Step, label: 'Basics' },
{ id: 2 as Step, label: 'Structure' },
{ id: 3 as Step, label: 'Schedule & timings' },
{ id: 4 as Step, label: 'Scoring pattern' },
{ id: 5 as Step, label: 'Championships & drops' },
{ id: 6 as Step, label: 'Review & confirm' },
];
const getStepTitle = (currentStep: Step): string => {
switch (currentStep) {
case 1:
return 'Step 1 — Basics';
case 2:
return 'Step 2 — Structure';
case 3:
return 'Step 3 — Schedule & timings';
case 4:
return 'Step 4 — Scoring pattern';
case 5:
return 'Step 5 — Championships & drops';
case 6:
return 'Step 6 — Review & confirm';
default:
return '';
}
};
const getStepSubtitle = (currentStep: Step): string => {
switch (currentStep) {
case 1:
return 'Give your league a clear name, description, and visibility.';
case 2:
return 'Choose whether this is a solo or team-based championship.';
case 3:
return 'Roughly outline how long your weekends and season should run.';
case 4:
return 'Pick a scoring pattern that matches your weekends.';
case 5:
return 'Decide which championships to track and how drops behave.';
case 6:
return 'Double-check the summary before creating your new league.';
default:
return '';
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<Heading level={1} className="mb-2">
Create a new league
</Heading>
<p className="text-sm text-gray-400 mb-4">
Configure basics, structure, schedule, scoring, and drop rules in a few
simple steps.
</p>
<div className="mb-4 flex flex-col gap-2">
<div className="flex flex-wrap gap-3">
{steps.map((wizardStep, index) => {
const isCompleted = wizardStep.id < step;
const isCurrent = wizardStep.id === step;
const baseCircleClasses =
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold';
const circleClasses = isCurrent
? 'bg-primary-blue text-white'
: isCompleted
? 'bg-primary-blue/20 border border-primary-blue text-primary-blue'
: 'bg-iron-gray border border-charcoal-outline text-gray-400';
return (
<div key={wizardStep.id} className="flex items-center gap-2">
<div className={baseCircleClasses + ' ' + circleClasses}>
{isCompleted ? '✓' : wizardStep.id}
</div>
<span
className={`text-xs ${
isCurrent
? 'text-white'
: isCompleted
? 'text-gray-300'
: 'text-gray-500'
}`}
>
{wizardStep.label}
</span>
{index < steps.length - 1 && (
<span className="mx-1 h-px w-6 bg-charcoal-outline/70" />
)}
</div>
);
})}
</div>
</div>
<Card>
<div>
<Heading level={2} className="text-2xl text-white">
{getStepTitle(step)}
</Heading>
<p className="mt-1 text-sm text-gray-400">
{getStepSubtitle(step)}
</p>
<hr className="my-4 border-charcoal-outline/40" />
</div>
{step === 1 && (
<LeagueBasicsSection
form={form}
onChange={setForm}
errors={errors.basics}
/>
)}
{step === 2 && (
<LeagueStructureSection
form={form}
onChange={setForm}
readOnly={false}
/>
)}
{step === 3 && (
<LeagueTimingsSection
form={form}
onChange={setForm}
errors={errors.timings}
weekendTemplate={weekendTemplate}
onWeekendTemplateChange={handleWeekendTemplateChange}
/>
)}
{step === 4 && (
<div className="space-y-4">
<ScoringPatternSection
scoring={form.scoring}
presets={presets}
readOnly={presetsLoading}
patternError={errors.scoring?.patternId}
onChangePatternId={(patternId) =>
setForm((prev) => ({
...prev,
scoring: {
...prev.scoring,
patternId,
customScoringEnabled: false,
},
}))
}
onToggleCustomScoring={() =>
setForm((prev) => ({
...prev,
scoring: {
...prev.scoring,
customScoringEnabled: !prev.scoring.customScoringEnabled,
},
}))
}
/>
</div>
)}
{step === 5 && (
<div className="space-y-6">
<div className="space-y-6">
<ChampionshipsSection form={form} onChange={setForm} readOnly={presetsLoading} />
</div>
<div className="space-y-3">
<LeagueDropSection form={form} onChange={setForm} readOnly={false} />
</div>
{errors.submit && (
<div className="rounded-md bg-warning-amber/10 p-3 border border-warning-amber/20 text-xs text-warning-amber">
{errors.submit}
</div>
)}
</div>
)}
{step === 6 && (
<div className="space-y-6">
<LeagueReviewSummary form={form} presets={presets} />
{errors.submit && (
<div className="rounded-md bg-warning-amber/10 p-3 border border-warning-amber/20 text-xs text-warning-amber">
{errors.submit}
</div>
)}
</div>
)}
</Card>
<div className="flex justify-between items-center">
<Button
type="button"
variant="secondary"
disabled={step === 1 || loading}
onClick={goToPreviousStep}
>
Back
</Button>
<div className="flex gap-2">
{step < 6 && (
<Button
type="button"
variant="primary"
disabled={loading}
onClick={goToNextStep}
>
Next
</Button>
)}
{step === 6 && (
<Button type="submit" variant="primary" disabled={loading}>
{loading ? 'Creating…' : 'Create league'}
</Button>
)}
</div>
</div>
</form>
);
}

View File

@@ -12,7 +12,14 @@ import {
getDriverStats,
getAllDriverRankings,
getDriverRepository,
getGetLeagueFullConfigQuery,
} from '@/lib/di-container';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import { LeagueBasicsSection } from './LeagueBasicsSection';
import { LeagueStructureSection } from './LeagueStructureSection';
import { LeagueScoringSection } from './LeagueScoringSection';
import { LeagueDropSection } from './LeagueDropSection';
import { LeagueTimingsSection } from './LeagueTimingsSection';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import type { MembershipRole } from '@/lib/leagueMembership';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
@@ -46,6 +53,8 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings' | 'disputes'>('members');
const [rejectReason, setRejectReason] = useState('');
const [configForm, setConfigForm] = useState<LeagueConfigFormModel | null>(null);
const [configLoading, setConfigLoading] = useState(false);
const loadJoinRequests = useCallback(async () => {
setLoading(true);
@@ -93,6 +102,23 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
loadOwner();
}, [league.ownerId]);
useEffect(() => {
async function loadConfig() {
setConfigLoading(true);
try {
const query = getGetLeagueFullConfigQuery();
const form = await query.execute({ leagueId: league.id });
setConfigForm(form);
} catch (err) {
console.error('Failed to load league config:', err);
} finally {
setConfigLoading(false);
}
}
loadConfig();
}, [league.id]);
const handleApproveRequest = async (requestId: string) => {
try {
const membershipRepo = getLeagueMembershipRepository();
@@ -464,113 +490,74 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
<Card>
<h2 className="text-xl font-semibold text-white mb-4">League Settings</h2>
<div className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
League Name
</label>
<p className="text-white">{league.name}</p>
</div>
{configLoading && !configForm ? (
<div className="py-6 text-sm text-gray-400">Loading configuration</div>
) : configForm ? (
<div className="space-y-8">
<LeagueBasicsSection form={configForm} readOnly />
<LeagueStructureSection form={configForm} readOnly />
<LeagueTimingsSection form={configForm} readOnly />
<LeagueScoringSection form={configForm} presets={[]} readOnly />
<LeagueDropSection form={configForm} readOnly />
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Description
</label>
<p className="text-white">{league.description}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-2 border-t border-charcoal-outline">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<div>
<label className="text-sm text-gray-500">Season / Series</label>
<label className="block text-sm font-medium text-gray-300 mb-2">
Season / Series
</label>
<p className="text-white">Alpha Demo Season</p>
</div>
<div>
<label className="text-sm text-gray-500">Points System</label>
<p className="text-white">{league.settings.pointsSystem.toUpperCase()}</p>
</div>
<div>
<label className="text-sm text-gray-500">Qualifying Format</label>
<p className="text-white capitalize">{league.settings.qualifyingFormat}</p>
<div className="rounded-lg border border-charcoal-outline bg-iron-gray/60 p-4 space-y-2">
<h3 className="text-sm font-semibold text-gray-200 mb-1">
At a glance
</h3>
<p className="text-xs text-gray-300">
<span className="font-semibold text-gray-200">Structure:</span>{' '}
{configForm.structure.mode === 'solo'
? `Solo • ${configForm.structure.maxDrivers} drivers`
: `Teams • ${configForm.structure.maxTeams ?? '—'} × ${
configForm.structure.driversPerTeam ?? '—'
} drivers (${configForm.structure.maxDrivers ?? '—'} total)`}
</p>
<p className="text-xs text-gray-300">
<span className="font-semibold text-gray-200">Schedule:</span>{' '}
{`${configForm.timings.roundsPlanned ?? '?'} rounds • ${
configForm.timings.qualifyingMinutes
} min Qualifying • ${configForm.timings.mainRaceMinutes} min Race`}
</p>
<p className="text-xs text-gray-300">
<span className="font-semibold text-gray-200">Scoring:</span>{' '}
{league.settings.pointsSystem.toUpperCase()}
</p>
</div>
</div>
{league.socialLinks && (
<div className="pt-4 border-t border-charcoal-outline space-y-2">
<h3 className="text-sm font-medium text-gray-300">Social Links</h3>
<div className="space-y-1 text-sm">
{league.socialLinks.discordUrl && (
<div className="flex items-center justify-between gap-3">
<span className="text-gray-400">Discord</span>
<a
href={league.socialLinks.discordUrl}
target="_blank"
rel="noreferrer"
className="text-primary-blue hover:underline break-all"
>
{league.socialLinks.discordUrl}
</a>
</div>
)}
{league.socialLinks.youtubeUrl && (
<div className="flex items-center justify-between gap-3">
<span className="text-gray-400">YouTube</span>
<a
href={league.socialLinks.youtubeUrl}
target="_blank"
rel="noreferrer"
className="text-red-400 hover:underline break-all"
>
{league.socialLinks.youtubeUrl}
</a>
</div>
)}
{league.socialLinks.websiteUrl && (
<div className="flex items-center justify-between gap-3">
<span className="text-gray-400">Website</span>
<a
href={league.socialLinks.websiteUrl}
target="_blank"
rel="noreferrer"
className="text-gray-100 hover:underline break-all"
>
{league.socialLinks.websiteUrl}
</a>
</div>
)}
{!league.socialLinks.discordUrl &&
!league.socialLinks.youtubeUrl &&
!league.socialLinks.websiteUrl && (
<p className="text-gray-500">
No social links configured for this league in the alpha demo.
</p>
)}
</div>
</div>
)}
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-300">League Owner</h3>
{ownerSummary ? (
<DriverSummaryPill
driver={ownerSummary.driver}
rating={ownerSummary.rating}
rank={ownerSummary.rank}
/>
) : (
<p className="text-sm text-gray-500">Loading owner details...</p>
)}
</div>
</div>
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-300">League Owner</h3>
{ownerSummary ? (
<DriverSummaryPill
driver={ownerSummary.driver}
rating={ownerSummary.rating}
rank={ownerSummary.rank}
/>
) : (
<p className="text-sm text-gray-500">Loading owner details...</p>
)}
<div className="pt-4 border-t border-charcoal-outline">
<p className="text-sm text-gray-400">
League settings editing is alpha-only and changes are not persisted yet.
</p>
</div>
</div>
<div className="pt-4 border-t border-charcoal-outline">
<p className="text-sm text-gray-400">
League settings editing is alpha-only and changes are not persisted yet.
</p>
) : (
<div className="py-6 text-sm text-gray-500">
Unable to load league configuration for this demo league.
</div>
</div>
)}
</Card>
)}

View File

@@ -0,0 +1,130 @@
'use client';
import Input from '@/components/ui/Input';
import type {
LeagueConfigFormModel,
} from '@gridpilot/racing/application';
interface LeagueBasicsSectionProps {
form: LeagueConfigFormModel;
onChange?: (form: LeagueConfigFormModel) => void;
errors?: {
name?: string;
visibility?: string;
};
readOnly?: boolean;
}
export function LeagueBasicsSection({
form,
onChange,
errors,
readOnly,
}: LeagueBasicsSectionProps) {
const basics = form.basics;
const disabled = readOnly || !onChange;
const updateBasics = (patch: Partial<typeof basics>) => {
if (!onChange) return;
onChange({
...form,
basics: {
...form.basics,
...patch,
},
});
};
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-white">Step 1 Basics</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
League name *
</label>
<Input
value={basics.name}
onChange={(e) => updateBasics({ name: e.target.value })}
placeholder="GridPilot Sprint Series"
error={!!errors?.name}
errorMessage={errors?.name}
disabled={disabled}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Description
</label>
<textarea
value={basics.description ?? ''}
onChange={(e) =>
updateBasics({
description: e.target.value,
})
}
rows={3}
disabled={disabled}
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue text-sm disabled:opacity-60 disabled:cursor-not-allowed"
placeholder="Weekly league with structured championships and live standings."
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<span className="block text-sm font-medium text-gray-300 mb-2">
Visibility *
</span>
<div className="flex gap-3">
<button
type="button"
disabled={disabled}
onClick={() =>
updateBasics({
visibility: 'public',
})
}
className={`flex-1 px-3 py-2 text-xs rounded-md border ${
basics.visibility === 'public'
? 'border-primary-blue bg-primary-blue/10 text-primary-blue'
: 'border-charcoal-outline bg-iron-gray text-gray-300'
} ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
>
Public
</button>
<button
type="button"
disabled={disabled}
onClick={() =>
updateBasics({
visibility: 'private',
})
}
className={`flex-1 px-3 py-2 text-xs rounded-md border ${
basics.visibility === 'private'
? 'border-primary-blue bg-primary-blue/10 text-primary-blue'
: 'border-charcoal-outline bg-iron-gray text-gray-300'
} ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
>
Private
</button>
</div>
{errors?.visibility && (
<p className="mt-1 text-xs text-warning-amber">
{errors.visibility}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Game
</label>
<Input value="iRacing" disabled />
</div>
</div>
</div>
</div>
);
}

View File

@@ -2,13 +2,13 @@
import Link from 'next/link';
import Image from 'next/image';
import type { LeagueDTO } from '@gridpilot/racing/application/dto/LeagueDTO';
import type { LeagueSummaryDTO } from '@gridpilot/racing/application/dto/LeagueSummaryDTO';
import Card from '../ui/Card';
import { getLeagueCoverClasses } from '@/lib/leagueCovers';
import { getImageService } from '@/lib/di-container';
interface LeagueCardProps {
league: LeagueDTO;
league: LeagueSummaryDTO;
onClick?: () => void;
}
@@ -57,6 +57,22 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
<p className="text-gray-400 text-sm line-clamp-2">
{league.description}
</p>
{league.structureSummary && (
<p className="text-xs text-gray-400">
{league.structureSummary}
</p>
)}
{league.scoringPatternSummary && (
<p className="text-xs text-gray-400">
{league.scoringPatternSummary}
</p>
)}
{league.timingSummary && (
<p className="text-xs text-gray-400">
{league.timingSummary}
</p>
)}
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<div className="flex flex-col text-xs text-gray-500">
@@ -70,19 +86,55 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
</Link>
</span>
<span className="mt-1 text-gray-400">
Slots:{' '}
Drivers:{' '}
<span className="text-white font-medium">
{typeof league.usedSlots === 'number' ? league.usedSlots : '—'}
{typeof league.usedDriverSlots === 'number'
? league.usedDriverSlots
: '—'}
</span>
{' / '}
<span className="text-gray-300">
{league.settings.maxDrivers ?? '—'}
</span>{' '}
used
{league.maxDrivers ?? '—'}
</span>
</span>
{typeof league.usedTeamSlots === 'number' ||
typeof league.maxTeams === 'number' ? (
<span className="mt-0.5 text-gray-400">
Teams:{' '}
<span className="text-white font-medium">
{typeof league.usedTeamSlots === 'number'
? league.usedTeamSlots
: '—'}
</span>
{' / '}
<span className="text-gray-300">
{league.maxTeams ?? '—'}
</span>
</span>
) : null}
</div>
<div className="text-xs text-primary-blue font-medium">
{league.settings.pointsSystem.toUpperCase()}
<div className="flex flex-col items-end text-xs text-gray-400">
{league.scoring ? (
<>
<span className="text-primary-blue font-semibold">
{league.scoring.gameName}
</span>
<span className="mt-0.5">
{league.scoring.primaryChampionshipType === 'driver'
? 'Driver championship'
: league.scoring.primaryChampionshipType === 'team'
? 'Team championship'
: league.scoring.primaryChampionshipType === 'nations'
? 'Nations championship'
: 'Trophy championship'}
</span>
<span className="mt-0.5">
{league.scoring.scoringPatternSummary}
</span>
</>
) : (
<span className="text-gray-500">Scoring: Not configured</span>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,137 @@
'use client';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import Input from '@/components/ui/Input';
import SegmentedControl from '@/components/ui/SegmentedControl';
interface LeagueDropSectionProps {
form: LeagueConfigFormModel;
onChange?: (form: LeagueConfigFormModel) => void;
readOnly?: boolean;
}
export function LeagueDropSection({
form,
onChange,
readOnly,
}: LeagueDropSectionProps) {
const disabled = readOnly || !onChange;
const dropPolicy = form.dropPolicy;
const updateDropPolicy = (
patch: Partial<LeagueConfigFormModel['dropPolicy']>,
) => {
if (!onChange) return;
onChange({
...form,
dropPolicy: {
...dropPolicy,
...patch,
},
});
};
const handleStrategyChange = (
strategy: LeagueConfigFormModel['dropPolicy']['strategy'],
) => {
if (strategy === 'none') {
updateDropPolicy({ strategy: 'none', n: undefined });
} else if (strategy === 'bestNResults') {
const n = dropPolicy.n ?? 6;
updateDropPolicy({ strategy: 'bestNResults', n });
} else if (strategy === 'dropWorstN') {
const n = dropPolicy.n ?? 2;
updateDropPolicy({ strategy: 'dropWorstN', n });
}
};
const handleNChange = (value: string) => {
const parsed = parseInt(value, 10);
updateDropPolicy({
n: Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed,
});
};
const computeSummary = () => {
if (dropPolicy.strategy === 'none') {
return 'All results will count towards the championship.';
}
if (dropPolicy.strategy === 'bestNResults') {
const n = dropPolicy.n;
if (typeof n === 'number' && n > 0) {
return `Best ${n} results will count; others are ignored.`;
}
return 'Best N results will count; others are ignored.';
}
if (dropPolicy.strategy === 'dropWorstN') {
const n = dropPolicy.n;
if (typeof n === 'number' && n > 0) {
return `Worst ${n} results will be dropped from the standings.`;
}
return 'Worst N results will be dropped from the standings.';
}
return 'All results will count towards the championship.';
};
const currentStrategyValue =
dropPolicy.strategy === 'none'
? 'all'
: dropPolicy.strategy === 'bestNResults'
? 'bestN'
: 'dropWorstN';
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-white">Drop rule</h3>
<p className="text-xs text-gray-400">
Decide whether to count every round or ignore a few worst results.
</p>
<SegmentedControl
options={[
{ value: 'all', label: 'All count' },
{ value: 'bestN', label: 'Best N' },
{ value: 'dropWorstN', label: 'Drop worst N' },
]}
value={currentStrategyValue}
onChange={(value) => {
if (disabled) return;
if (value === 'all') {
handleStrategyChange('none');
} else if (value === 'bestN') {
handleStrategyChange('bestNResults');
} else if (value === 'dropWorstN') {
handleStrategyChange('dropWorstN');
}
}}
/>
{(dropPolicy.strategy === 'bestNResults' ||
dropPolicy.strategy === 'dropWorstN') && (
<div className="mt-2 max-w-[140px]">
<label className="mb-1 block text-xs font-medium text-gray-300">
N
</label>
<Input
type="number"
value={
typeof dropPolicy.n === 'number' && dropPolicy.n > 0
? String(dropPolicy.n)
: ''
}
onChange={(e) => handleNChange(e.target.value)}
disabled={disabled}
min={1}
/>
<p className="mt-1 text-[11px] text-gray-500">
{dropPolicy.strategy === 'bestNResults'
? 'For example, best 6 of 10 rounds count.'
: 'For example, drop the worst 2 results.'}
</p>
</div>
)}
<p className="mt-3 text-xs text-gray-400">{computeSummary()}</p>
</div>
);
}

View File

@@ -0,0 +1,210 @@
'use client';
import Card from '@/components/ui/Card';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
interface LeagueReviewSummaryProps {
form: LeagueConfigFormModel;
presets: LeagueScoringPresetDTO[];
}
export default function LeagueReviewSummary({ form, presets }: LeagueReviewSummaryProps) {
const { basics, structure, timings, scoring, championships, dropPolicy } = form;
const modeLabel =
structure.mode === 'solo'
? 'Drivers only (solo)'
: 'Teams with fixed drivers per team';
const capacitySentence = (() => {
if (structure.mode === 'solo') {
if (typeof structure.maxDrivers === 'number') {
return `Up to ${structure.maxDrivers} drivers`;
}
return 'Capacity not fully specified';
}
const parts: string[] = [];
if (typeof structure.maxTeams === 'number') {
parts.push(`Teams: ${structure.maxTeams}`);
}
if (typeof structure.driversPerTeam === 'number') {
parts.push(`Drivers per team: ${structure.driversPerTeam}`);
}
if (typeof structure.maxDrivers === 'number') {
parts.push(`Max grid: ${structure.maxDrivers}`);
}
if (parts.length === 0) {
return '—';
}
return parts.join(', ');
})();
const formatMinutes = (value: number | undefined) => {
if (typeof value !== 'number' || value <= 0) return '—';
return `${value} min`;
};
const dropRuleSentence = (() => {
if (dropPolicy.strategy === 'none') {
return 'All results will count towards the championship.';
}
if (dropPolicy.strategy === 'bestNResults') {
if (typeof dropPolicy.n === 'number' && dropPolicy.n > 0) {
return `Best ${dropPolicy.n} results will count; others are ignored.`;
}
return 'Best N results will count; others are ignored.';
}
if (dropPolicy.strategy === 'dropWorstN') {
if (typeof dropPolicy.n === 'number' && dropPolicy.n > 0) {
return `Worst ${dropPolicy.n} results will be dropped from the standings.`;
}
return 'Worst N results will be dropped from the standings.';
}
return 'All results will count towards the championship.';
})();
const preset =
presets.find((p) => p.id === scoring.patternId) ?? null;
const scoringPresetName = preset ? preset.name : scoring.patternId ? 'Preset not found' : '—';
const scoringPatternSummary = preset?.sessionSummary ?? '—';
const dropPolicySummary = dropRuleSentence;
const enabledChampionshipsLabels: string[] = [];
if (championships.enableDriverChampionship) enabledChampionshipsLabels.push('Driver');
if (championships.enableTeamChampionship) enabledChampionshipsLabels.push('Team');
if (championships.enableNationsChampionship) enabledChampionshipsLabels.push('Nations Cup');
if (championships.enableTrophyChampionship) enabledChampionshipsLabels.push('Trophy');
const championshipsSummary =
enabledChampionshipsLabels.length === 0
? 'None enabled yet.'
: enabledChampionshipsLabels.join(', ');
const visibilityLabel = basics.visibility === 'public' ? 'Public' : 'Private';
const gameLabel = 'iRacing';
return (
<Card className="bg-iron-gray/80">
<div className="space-y-6 text-sm text-gray-200">
{/* 1. Basics & visibility */}
<section className="space-y-3">
<h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">
Basics & visibility
</h3>
<dl className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1">
<dt className="text-xs text-gray-500">Name</dt>
<dd className="font-medium text-white">{basics.name || '—'}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Visibility</dt>
<dd>{visibilityLabel}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Game</dt>
<dd>{gameLabel}</dd>
</div>
{basics.description && (
<div className="space-y-1 md:col-span-2">
<dt className="text-xs text-gray-500">Description</dt>
<dd className="text-gray-300">{basics.description}</dd>
</div>
)}
</dl>
</section>
{/* 2. Structure & capacity */}
<section className="space-y-3">
<h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">
Structure & capacity
</h3>
<dl className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1">
<dt className="text-xs text-gray-500">Mode</dt>
<dd>{modeLabel}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Capacity</dt>
<dd>{capacitySentence}</dd>
</div>
</dl>
</section>
{/* 3. Schedule & timings */}
<section className="space-y-3">
<h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">
Schedule & timings
</h3>
<dl className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1">
<dt className="text-xs text-gray-500">Planned rounds</dt>
<dd>{typeof timings.roundsPlanned === 'number' ? timings.roundsPlanned : '—'}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Sessions per weekend</dt>
<dd>{timings.sessionCount ?? '—'}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Practice</dt>
<dd>{formatMinutes(timings.practiceMinutes)}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Qualifying</dt>
<dd>{formatMinutes(timings.qualifyingMinutes)}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Sprint</dt>
<dd>{formatMinutes(timings.sprintRaceMinutes)}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Main race</dt>
<dd>{formatMinutes(timings.mainRaceMinutes)}</dd>
</div>
</dl>
</section>
{/* 4. Scoring & drops */}
<section className="space-y-3">
<h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">
Scoring & drops
</h3>
<dl className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1">
<dt className="text-xs text-gray-500">Scoring pattern</dt>
<dd>{scoringPresetName}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Pattern summary</dt>
<dd>{scoringPatternSummary}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Drop rule</dt>
<dd>{dropPolicySummary}</dd>
</div>
{scoring.customScoringEnabled && (
<div className="space-y-1">
<dt className="text-xs text-gray-500">Custom scoring</dt>
<dd>Custom scoring flagged</dd>
</div>
)}
</dl>
</section>
{/* 5. Championships */}
<section className="space-y-3">
<h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">
Championships
</h3>
<dl className="grid grid-cols-1 gap-4">
<div className="space-y-1">
<dt className="text-xs text-gray-500">Enabled championships</dt>
<dd>{championshipsSummary}</dd>
</div>
</dl>
</section>
</div>
</Card>
);
}

View File

@@ -0,0 +1,489 @@
'use client';
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import Button from '@/components/ui/Button';
import PresetCard from '@/components/ui/PresetCard';
interface LeagueScoringSectionProps {
form: LeagueConfigFormModel;
presets: LeagueScoringPresetDTO[];
onChange?: (form: LeagueConfigFormModel) => void;
readOnly?: boolean;
/**
* When true, only render the scoring pattern panel.
*/
patternOnly?: boolean;
/**
* When true, only render the championships panel.
*/
championshipsOnly?: boolean;
}
interface ScoringPatternSectionProps {
scoring: LeagueConfigFormModel['scoring'];
presets: LeagueScoringPresetDTO[];
readOnly?: boolean;
patternError?: string;
onChangePatternId?: (patternId: string) => void;
onToggleCustomScoring?: () => void;
}
interface ChampionshipsSectionProps {
form: LeagueConfigFormModel;
onChange?: (form: LeagueConfigFormModel) => void;
readOnly?: boolean;
}
export function LeagueScoringSection({
form,
presets,
onChange,
readOnly,
patternOnly,
championshipsOnly,
}: LeagueScoringSectionProps) {
const disabled = readOnly || !onChange;
const updateScoring = (
patch: Partial<LeagueConfigFormModel['scoring']>,
) => {
if (!onChange) return;
onChange({
...form,
scoring: {
...form.scoring,
...patch,
},
});
};
const updateChampionships = (
patch: Partial<LeagueConfigFormModel['championships']>,
) => {
if (!onChange) return;
onChange({
...form,
championships: {
...form.championships,
...patch,
},
});
};
const handleSelectPreset = (presetId: string) => {
if (disabled) return;
updateScoring({
patternId: presetId,
customScoringEnabled: false,
});
};
const handleToggleCustomScoring = () => {
if (disabled) return;
const current = !!form.scoring.customScoringEnabled;
updateScoring({
customScoringEnabled: !current,
});
};
const currentPreset =
presets.find((p) => p.id === form.scoring.patternId) ?? null;
const isTeamsMode = form.structure.mode === 'fixedTeams';
const renderPrimaryChampionshipLabel = () => {
if (!currentPreset) {
return '—';
}
switch (currentPreset.primaryChampionshipType) {
case 'driver':
return 'Driver championship';
case 'team':
return 'Team championship';
case 'nations':
return 'Nations championship';
case 'trophy':
return 'Trophy championship';
default:
return currentPreset.primaryChampionshipType;
}
};
const selectedPreset =
currentPreset ??
(presets.length > 0
? presets.find((p) => p.id === form.scoring.patternId) ?? null
: null);
const patternPanel = (
<ScoringPatternSection
scoring={form.scoring}
presets={presets}
readOnly={readOnly}
onChangePatternId={
!readOnly && onChange ? (id) => handleSelectPreset(id) : undefined
}
onToggleCustomScoring={disabled ? undefined : handleToggleCustomScoring}
/>
);
const championshipsPanel = (
<ChampionshipsSection form={form} onChange={onChange} readOnly={readOnly} />
);
if (patternOnly) {
return <div>{patternPanel}</div>;
}
if (championshipsOnly) {
return <div>{championshipsPanel}</div>;
}
return (
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)]">
{patternPanel}
{championshipsPanel}
</div>
);
}
/**
* Step 4 scoring pattern preset picker used by the wizard.
*/
export function ScoringPatternSection({
scoring,
presets,
readOnly,
patternError,
onChangePatternId,
onToggleCustomScoring,
}: ScoringPatternSectionProps) {
const disabled = readOnly || !onChangePatternId;
const currentPreset =
presets.find((p) => p.id === scoring.patternId) ?? null;
const renderPrimaryLabel = (preset: LeagueScoringPresetDTO) => {
switch (preset.primaryChampionshipType) {
case 'driver':
return 'Driver focus';
case 'team':
return 'Team focus';
case 'nations':
return 'Nations focus';
case 'trophy':
return 'Trophy / cup focus';
default:
return preset.primaryChampionshipType;
}
};
const handleSelect = (presetId: string) => {
if (disabled) return;
onChangePatternId?.(presetId);
};
return (
<section className="space-y-4">
<header className="space-y-1">
<h3 className="text-sm font-semibold text-white">Scoring pattern</h3>
<p className="text-xs text-gray-400">
Pick an overall scoring style; details can evolve later.
</p>
</header>
{presets.length === 0 ? (
<p className="text-sm text-gray-400">No presets available.</p>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{presets.map((preset) => (
<PresetCard
key={preset.id}
title={preset.name}
subtitle={preset.sessionSummary}
primaryTag={renderPrimaryLabel(preset)}
stats={[
{ label: 'Sessions', value: preset.sessionSummary },
{ label: 'Drops', value: preset.dropPolicySummary },
{ label: 'Bonuses', value: preset.bonusSummary },
]}
selected={scoring.patternId === preset.id}
disabled={readOnly}
onSelect={() => handleSelect(preset.id)}
/>
))}
</div>
)}
{patternError && (
<p className="text-xs text-warning-amber">{patternError}</p>
)}
<div className="mt-3 space-y-2 rounded-lg border border-charcoal-outline/70 bg-deep-graphite/70 p-3 text-xs text-gray-300">
<div className="font-semibold text-gray-200">Selected pattern</div>
{currentPreset ? (
<div className="mt-1 space-y-1 text-[11px]">
<div className="inline-flex items-center gap-2">
<span className="inline-flex rounded-full bg-primary-blue/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary-blue">
{currentPreset.name}
</span>
</div>
<p className="text-gray-300">Sessions: {currentPreset.sessionSummary}</p>
<p className="text-gray-300">
Points focus: {currentPreset ? renderPrimaryLabel(currentPreset) : '—'}
</p>
<p className="text-gray-300">
Default drops: {currentPreset.dropPolicySummary}
</p>
</div>
) : (
<p className="text-[11px] text-gray-500">
No pattern selected yet. Pick a card above to define your scoring style.
</p>
)}
</div>
<div className="mt-3 flex items-center justify-between gap-4 rounded-lg border border-charcoal-outline/70 bg-deep-graphite/60 p-3">
<div className="space-y-1">
<p className="text-xs font-medium text-gray-200">
Custom scoring (advanced)
</p>
<p className="text-[11px] text-gray-500">
In this alpha, presets still define the actual scoring; this flag marks intent only.
</p>
</div>
{readOnly ? (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
scoring.customScoringEnabled
? 'bg-primary-blue/15 text-primary-blue'
: 'bg-charcoal-outline/60 text-gray-300'
}`}
>
{scoring.customScoringEnabled
? 'Custom scoring flagged'
: 'Using preset scoring'}
</span>
) : (
<Button
type="button"
variant={scoring.customScoringEnabled ? 'primary' : 'secondary'}
disabled={!onToggleCustomScoring}
onClick={onToggleCustomScoring}
className="shrink-0"
>
{scoring.customScoringEnabled ? 'Custom scoring flagged' : 'Use preset scoring'}
</Button>
)}
</div>
</section>
);
}
/**
* Step 5 championships-only panel used by the wizard.
*/
export function ChampionshipsSection({
form,
onChange,
readOnly,
}: ChampionshipsSectionProps) {
const disabled = readOnly || !onChange;
const isTeamsMode = form.structure.mode === 'fixedTeams';
const updateChampionships = (
patch: Partial<LeagueConfigFormModel['championships']>,
) => {
if (!onChange) return;
onChange({
...form,
championships: {
...form.championships,
...patch,
},
});
};
return (
<section className="space-y-4">
<header className="space-y-1">
<h3 className="text-sm font-semibold text-white">Championships</h3>
<p className="text-xs text-gray-400">
Pick which standings you want to maintain for this season.
</p>
</header>
<div className="space-y-3 text-xs text-gray-300">
{/* Driver championship */}
<div className="flex items-start justify-between gap-3 rounded-lg bg-deep-graphite/40 p-3">
<div className="space-y-0.5">
<div className="text-xs font-medium text-gray-100">Driver championship</div>
<p className="text-[11px] text-gray-500">
Per-driver season standings across all points-scoring sessions.
</p>
</div>
<div className="shrink-0">
{readOnly ? (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
form.championships.enableDriverChampionship
? 'bg-primary-blue/15 text-primary-blue'
: 'bg-charcoal-outline/60 text-gray-300'
}`}
>
{form.championships.enableDriverChampionship ? 'On' : 'Off'}
</span>
) : (
<label className="inline-flex items-center gap-2 text-xs">
<input
type="checkbox"
className="h-4 w-4 rounded border-charcoal-outline bg-iron-gray text-primary-blue"
checked={form.championships.enableDriverChampionship}
disabled={disabled}
onChange={(e) =>
updateChampionships({
enableDriverChampionship: e.target.checked,
})
}
/>
<span className="text-gray-200">On</span>
</label>
)}
</div>
</div>
{/* Team championship */}
<div className="flex items-start justify-between gap-3 rounded-lg bg-deep-graphite/40 p-3">
<div className="space-y-0.5">
<div className="text-xs font-medium text-gray-100">Team championship</div>
<p className="text-[11px] text-gray-500">
Aggregated season standings for fixed teams.
</p>
{!isTeamsMode && (
<p className="text-[10px] text-gray-500">
Enable team mode in Structure to turn this on.
</p>
)}
</div>
<div className="shrink-0">
{readOnly ? (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
isTeamsMode && form.championships.enableTeamChampionship
? 'bg-primary-blue/15 text-primary-blue'
: 'bg-charcoal-outline/60 text-gray-300'
}`}
>
{isTeamsMode && form.championships.enableTeamChampionship ? 'On' : 'Off'}
</span>
) : (
<label className="inline-flex items-center gap-2 text-xs">
<input
type="checkbox"
className="h-4 w-4 rounded border-charcoal-outline bg-iron-gray text-primary-blue"
checked={
isTeamsMode && form.championships.enableTeamChampionship
}
disabled={disabled || !isTeamsMode}
onChange={(e) =>
updateChampionships({
enableTeamChampionship: e.target.checked,
})
}
/>
<span
className={`text-gray-200 ${
!isTeamsMode ? 'opacity-60' : ''
}`}
>
On
</span>
</label>
)}
</div>
</div>
{/* Nations championship */}
<div className="flex items-start justify-between gap-3 rounded-lg bg-deep-graphite/40 p-3">
<div className="space-y-0.5">
<div className="text-xs font-medium text-gray-100">Nations Cup</div>
<p className="text-[11px] text-gray-500">
Standings grouped by drivers' nationality or country flag.
</p>
</div>
<div className="shrink-0">
{readOnly ? (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
form.championships.enableNationsChampionship
? 'bg-primary-blue/15 text-primary-blue'
: 'bg-charcoal-outline/60 text-gray-300'
}`}
>
{form.championships.enableNationsChampionship ? 'On' : 'Off'}
</span>
) : (
<label className="inline-flex items-center gap-2 text-xs">
<input
type="checkbox"
className="h-4 w-4 rounded border-charcoal-outline bg-iron-gray text-primary-blue"
checked={form.championships.enableNationsChampionship}
disabled={disabled}
onChange={(e) =>
updateChampionships({
enableNationsChampionship: e.target.checked,
})
}
/>
<span className="text-gray-200">On</span>
</label>
)}
</div>
</div>
{/* Trophy championship */}
<div className="flex items-start justify-between gap-3 rounded-lg bg-deep-graphite/40 p-3">
<div className="space-y-0.5">
<div className="text-xs font-medium text-gray-100">Trophy / cup</div>
<p className="text-[11px] text-gray-500">
Extra cup-style standings for special categories or invite-only groups.
</p>
</div>
<div className="shrink-0">
{readOnly ? (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
form.championships.enableTrophyChampionship
? 'bg-primary-blue/15 text-primary-blue'
: 'bg-charcoal-outline/60 text-gray-300'
}`}
>
{form.championships.enableTrophyChampionship ? 'On' : 'Off'}
</span>
) : (
<label className="inline-flex items-center gap-2 text-xs">
<input
type="checkbox"
className="h-4 w-4 rounded border-charcoal-outline bg-iron-gray text-primary-blue"
checked={form.championships.enableTrophyChampionship}
disabled={disabled}
onChange={(e) =>
updateChampionships({
enableTrophyChampionship: e.target.checked,
})
}
/>
<span className="text-gray-200">On</span>
</label>
)}
</div>
</div>
<p className="pt-1 text-[10px] text-gray-500">
For this alpha slice, only driver standings are fully calculated, but these toggles express intent for future seasons.
</p>
</div>
</section>
);
}

View File

@@ -0,0 +1,190 @@
'use client';
import type { LeagueScoringConfigDTO } from '@gridpilot/racing/application/dto/LeagueScoringConfigDTO';
interface LeagueScoringTabProps {
scoringConfig: LeagueScoringConfigDTO | null;
practiceMinutes?: number;
qualifyingMinutes?: number;
sprintRaceMinutes?: number;
mainRaceMinutes?: number;
}
export default function LeagueScoringTab({
scoringConfig,
practiceMinutes,
qualifyingMinutes,
sprintRaceMinutes,
mainRaceMinutes,
}: LeagueScoringTabProps) {
if (!scoringConfig) {
return (
<div className="text-sm text-gray-400 py-6">
Scoring configuration is not available for this league yet.
</div>
);
}
const primaryChampionship =
scoringConfig.championships.find((c) => c.type === 'driver') ??
scoringConfig.championships[0];
const resolvedPractice = practiceMinutes ?? 20;
const resolvedQualifying = qualifyingMinutes ?? 30;
const resolvedSprint = sprintRaceMinutes;
const resolvedMain = mainRaceMinutes ?? 40;
return (
<div className="space-y-6">
<div className="border-b border-charcoal-outline pb-4 space-y-3">
<h2 className="text-xl font-semibold text-white mb-1">
Scoring overview
</h2>
<p className="text-sm text-gray-400">
{scoringConfig.gameName}{' '}
{scoringConfig.scoringPresetName
? `${scoringConfig.scoringPresetName}`
: '• Custom scoring'}{' '}
{scoringConfig.dropPolicySummary}
</p>
{primaryChampionship && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-gray-200">
Weekend structure & timings
</h3>
<div className="flex flex-wrap gap-2 text-xs">
{primaryChampionship.sessionTypes.map((session) => (
<span
key={session}
className="px-2 py-0.5 rounded-full bg-charcoal-outline/60 text-xs text-gray-200"
>
{session}
</span>
))}
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs text-gray-300">
<p>
<span className="text-gray-400">Practice:</span>{' '}
{resolvedPractice ? `${resolvedPractice} min` : '—'}
</p>
<p>
<span className="text-gray-400">Qualifying:</span>{' '}
{resolvedQualifying} min
</p>
<p>
<span className="text-gray-400">Sprint:</span>{' '}
{resolvedSprint ? `${resolvedSprint} min` : '—'}
</p>
<p>
<span className="text-gray-400">Main race:</span>{' '}
{resolvedMain} min
</p>
</div>
</div>
)}
</div>
{scoringConfig.championships.map((championship) => (
<div
key={championship.id}
className="border border-charcoal-outline rounded-lg bg-iron-gray/40 p-4 space-y-4"
>
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-white">
{championship.name}
</h3>
<p className="text-xs uppercase tracking-wide text-gray-500">
{championship.type === 'driver'
? 'Driver championship'
: championship.type === 'team'
? 'Team championship'
: championship.type === 'nations'
? 'Nations championship'
: 'Trophy championship'}
</p>
</div>
{championship.sessionTypes.length > 0 && (
<div className="flex flex-wrap gap-1 justify-end">
{championship.sessionTypes.map((session) => (
<span
key={session}
className="px-2 py-0.5 rounded-full bg-charcoal-outline/60 text-xs text-gray-200"
>
{session}
</span>
))}
</div>
)}
</div>
{championship.pointsPreview.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-400 mb-2">
Points preview (top positions)
</h4>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-charcoal-outline/60">
<th className="text-left py-2 pr-2 text-gray-400">
Session
</th>
<th className="text-left py-2 px-2 text-gray-400">
Position
</th>
<th className="text-left py-2 px-2 text-gray-400">
Points
</th>
</tr>
</thead>
<tbody>
{championship.pointsPreview.map((row, index) => (
<tr
key={`${row.sessionType}-${row.position}-${index}`}
className="border-b border-charcoal-outline/30"
>
<td className="py-1.5 pr-2 text-gray-200">
{row.sessionType}
</td>
<td className="py-1.5 px-2 text-gray-200">
P{row.position}
</td>
<td className="py-1.5 px-2 text-white">
{row.points}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{championship.bonusSummary.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-400 mb-1">
Bonus points
</h4>
<ul className="list-disc list-inside text-xs text-gray-300 space-y-1">
{championship.bonusSummary.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
)}
<div>
<h4 className="text-xs font-semibold text-gray-400 mb-1">
Drop score policy
</h4>
<p className="text-xs text-gray-300">
{championship.dropPolicyDescription}
</p>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,246 @@
'use client';
import Input from '@/components/ui/Input';
import SegmentedControl from '@/components/ui/SegmentedControl';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
interface LeagueStructureSectionProps {
form: LeagueConfigFormModel;
onChange?: (form: LeagueConfigFormModel) => void;
readOnly?: boolean;
}
export function LeagueStructureSection({
form,
onChange,
readOnly,
}: LeagueStructureSectionProps) {
const disabled = readOnly || !onChange;
const structure = form.structure;
const updateStructure = (
patch: Partial<LeagueConfigFormModel['structure']>,
) => {
if (!onChange) return;
const nextStructure = {
...structure,
...patch,
};
let nextForm: LeagueConfigFormModel = {
...form,
structure: nextStructure,
};
if (nextStructure.mode === 'fixedTeams') {
const maxTeams =
typeof nextStructure.maxTeams === 'number' &&
nextStructure.maxTeams > 0
? nextStructure.maxTeams
: 1;
const driversPerTeam =
typeof nextStructure.driversPerTeam === 'number' &&
nextStructure.driversPerTeam > 0
? nextStructure.driversPerTeam
: 1;
const maxDrivers = maxTeams * driversPerTeam;
nextForm = {
...nextForm,
structure: {
...nextStructure,
maxTeams,
driversPerTeam,
maxDrivers,
},
};
}
if (nextStructure.mode === 'solo') {
nextForm = {
...nextForm,
structure: {
...nextStructure,
maxTeams: undefined,
driversPerTeam: undefined,
},
};
}
onChange(nextForm);
};
const handleModeChange = (mode: 'solo' | 'fixedTeams') => {
if (mode === structure.mode) return;
if (mode === 'solo') {
updateStructure({
mode: 'solo',
maxDrivers: structure.maxDrivers || 24,
maxTeams: undefined,
driversPerTeam: undefined,
});
} else {
const maxTeams = structure.maxTeams ?? 12;
const driversPerTeam = structure.driversPerTeam ?? 2;
updateStructure({
mode: 'fixedTeams',
maxTeams,
driversPerTeam,
maxDrivers: maxTeams * driversPerTeam,
});
}
};
const handleMaxDriversChange = (value: string) => {
const parsed = parseInt(value, 10);
updateStructure({
maxDrivers: Number.isNaN(parsed) ? 0 : parsed,
});
};
const handleMaxTeamsChange = (value: string) => {
const parsed = parseInt(value, 10);
const maxTeams = Number.isNaN(parsed) ? 0 : parsed;
const driversPerTeam = structure.driversPerTeam ?? 2;
updateStructure({
maxTeams,
driversPerTeam,
maxDrivers:
maxTeams > 0 && driversPerTeam > 0
? maxTeams * driversPerTeam
: structure.maxDrivers,
});
};
const handleDriversPerTeamChange = (value: string) => {
const parsed = parseInt(value, 10);
const driversPerTeam = Number.isNaN(parsed) ? 0 : parsed;
const maxTeams = structure.maxTeams ?? 12;
updateStructure({
driversPerTeam,
maxTeams,
maxDrivers:
maxTeams > 0 && driversPerTeam > 0
? maxTeams * driversPerTeam
: structure.maxDrivers,
});
};
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-white">
Step 2 Structure &amp; capacity
</h2>
<div className="space-y-5">
<div>
<span className="block text-sm font-medium text-gray-300 mb-2">
League structure
</span>
<SegmentedControl
options={[
{
value: 'solo',
label: 'Drivers only (Solo)',
description: 'Individual drivers score points.',
},
{
value: 'fixedTeams',
label: 'Teams',
description: 'Teams with fixed drivers per team.',
},
]}
value={structure.mode}
onChange={
disabled
? undefined
: (mode) => handleModeChange(mode as 'solo' | 'fixedTeams')
}
/>
</div>
{structure.mode === 'solo' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300">
Max drivers
</label>
<p className="text-xs text-gray-500">
Typical club leagues use 2030
</p>
<div className="mt-2">
<Input
type="number"
value={structure.maxDrivers ?? 24}
onChange={(e) => handleMaxDriversChange(e.target.value)}
disabled={disabled}
min={1}
max={64}
className="w-28"
/>
</div>
</div>
</div>
)}
{structure.mode === 'fixedTeams' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300">
Max teams
</label>
<p className="text-xs text-gray-500">
Roughly how many teams you expect.
</p>
<div className="mt-2">
<Input
type="number"
value={structure.maxTeams ?? 12}
onChange={(e) => handleMaxTeamsChange(e.target.value)}
disabled={disabled}
min={1}
max={32}
className="w-28"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300">
Drivers per team
</label>
<p className="text-xs text-gray-500">
Common values are 23 drivers.
</p>
<div className="mt-2">
<Input
type="number"
value={structure.driversPerTeam ?? 2}
onChange={(e) => handleDriversPerTeamChange(e.target.value)}
disabled={disabled}
min={1}
max={6}
className="w-28"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300">
Max drivers (derived)
</label>
<p className="text-xs text-gray-500">
Calculated as teams × drivers per team.
</p>
<div className="mt-2 max-w-[7rem]">
<Input
type="number"
value={structure.maxDrivers ?? 0}
disabled
/>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,751 @@
'use client';
import { useEffect, useState } from 'react';
import type {
LeagueConfigFormModel,
LeagueSchedulePreviewDTO,
} from '@gridpilot/racing/application';
import type { Weekday } from '@gridpilot/racing/domain/value-objects/Weekday';
import Heading from '@/components/ui/Heading';
import Input from '@/components/ui/Input';
import SegmentedControl from '@/components/ui/SegmentedControl';
type RecurrenceStrategy = NonNullable<LeagueConfigFormModel['timings']>['recurrenceStrategy'];
interface LeagueTimingsSectionProps {
form: LeagueConfigFormModel;
onChange?: (form: LeagueConfigFormModel) => void;
readOnly?: boolean;
errors?: {
qualifyingMinutes?: string;
mainRaceMinutes?: string;
roundsPlanned?: string;
};
/**
* Optional override for the section heading.
* When omitted, defaults to "Schedule & timings".
*/
title?: string;
/**
* Local wizard-only weekend template identifier.
*/
weekendTemplate?: string;
/**
* Callback when the weekend template selection changes.
*/
onWeekendTemplateChange?: (template: string) => void;
}
export function LeagueTimingsSection({
form,
onChange,
readOnly,
errors,
title,
weekendTemplate,
onWeekendTemplateChange,
}: LeagueTimingsSectionProps) {
const disabled = readOnly || !onChange;
const timings = form.timings;
const [schedulePreview, setSchedulePreview] =
useState<LeagueSchedulePreviewDTO | null>(null);
const [schedulePreviewError, setSchedulePreviewError] = useState<string | null>(
null,
);
const [isSchedulePreviewLoading, setIsSchedulePreviewLoading] = useState(false);
const updateTimings = (
patch: Partial<NonNullable<LeagueConfigFormModel['timings']>>,
) => {
if (!onChange) return;
onChange({
...form,
timings: {
...timings,
...patch,
},
});
};
const handleRoundsChange = (value: string) => {
if (!onChange) return;
const trimmed = value.trim();
if (trimmed === '') {
updateTimings({ roundsPlanned: undefined });
return;
}
const parsed = parseInt(trimmed, 10);
updateTimings({
roundsPlanned: Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed,
});
};
const showSprint =
form.scoring.patternId === 'sprint-main-driver' ||
(typeof timings.sprintRaceMinutes === 'number' && timings.sprintRaceMinutes > 0);
const recurrenceStrategy: RecurrenceStrategy =
timings.recurrenceStrategy ?? 'weekly';
const weekdays: Weekday[] = (timings.weekdays ?? []) as Weekday[];
const handleWeekdayToggle = (day: Weekday) => {
const current = new Set(weekdays);
if (current.has(day)) {
current.delete(day);
} else {
current.add(day);
}
updateTimings({ weekdays: Array.from(current).sort() });
};
const handleRecurrenceChange = (value: string) => {
updateTimings({
recurrenceStrategy: value as RecurrenceStrategy,
});
};
const requiresWeekdaySelection =
(recurrenceStrategy === 'weekly' || recurrenceStrategy === 'everyNWeeks') &&
weekdays.length === 0;
useEffect(() => {
if (!timings) return;
const {
seasonStartDate,
raceStartTime,
timezoneId,
recurrenceStrategy: currentStrategy,
intervalWeeks,
weekdays: currentWeekdays,
monthlyOrdinal,
monthlyWeekday,
roundsPlanned,
} = timings;
const hasCoreFields =
!!seasonStartDate &&
!!raceStartTime &&
!!timezoneId &&
!!currentStrategy &&
typeof roundsPlanned === 'number' &&
roundsPlanned > 0;
if (!hasCoreFields) {
setSchedulePreview(null);
setSchedulePreviewError(null);
setIsSchedulePreviewLoading(false);
return;
}
if (
(currentStrategy === 'weekly' || currentStrategy === 'everyNWeeks') &&
(!currentWeekdays || currentWeekdays.length === 0)
) {
setSchedulePreview(null);
setSchedulePreviewError(null);
setIsSchedulePreviewLoading(false);
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(async () => {
try {
setIsSchedulePreviewLoading(true);
setSchedulePreviewError(null);
const payload = {
seasonStartDate,
raceStartTime,
timezoneId,
recurrenceStrategy: currentStrategy,
intervalWeeks,
weekdays: currentWeekdays,
monthlyOrdinal,
monthlyWeekday,
plannedRounds: roundsPlanned,
};
const response = await fetch('/api/leagues/schedule-preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
signal: controller.signal,
});
if (!response.ok) {
const message =
response.status === 400
? 'Could not compute schedule with current values.'
: 'Failed to load schedule preview.';
setSchedulePreviewError(message);
return;
}
const data = (await response.json()) as LeagueSchedulePreviewDTO;
setSchedulePreview(data);
} catch (err) {
if ((err as any).name === 'AbortError') {
return;
}
setSchedulePreviewError('Could not compute schedule with current values.');
} finally {
setIsSchedulePreviewLoading(false);
}
}, 400);
return () => {
clearTimeout(timeoutId);
controller.abort();
};
}, [
timings?.seasonStartDate,
timings?.raceStartTime,
timings?.timezoneId,
timings?.recurrenceStrategy,
timings?.intervalWeeks,
timings?.monthlyOrdinal,
timings?.monthlyWeekday,
timings?.roundsPlanned,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(timings?.weekdays ?? []),
]);
if (disabled) {
return (
<div className="space-y-4">
<Heading level={3} className="text-lg font-semibold text-white">
{title ?? 'Schedule & timings'}
</Heading>
<div className="space-y-3 text-sm text-gray-300">
<div>
<span className="font-medium text-gray-200">Planned rounds:</span>{' '}
<span>{timings.roundsPlanned ?? '—'}</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<span className="font-medium text-gray-200">Qualifying:</span>{' '}
<span>{timings.qualifyingMinutes} min</span>
</div>
<div>
<span className="font-medium text-gray-200">Main race:</span>{' '}
<span>{timings.mainRaceMinutes} min</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<span className="font-medium text-gray-200">Practice:</span>{' '}
<span>
{typeof timings.practiceMinutes === 'number'
? `${timings.practiceMinutes} min`
: '—'}
</span>
</div>
{showSprint && (
<div>
<span className="font-medium text-gray-200">Sprint:</span>{' '}
<span>
{typeof timings.sprintRaceMinutes === 'number'
? `${timings.sprintRaceMinutes} min`
: '—'}
</span>
</div>
)}
</div>
<div>
<span className="font-medium text-gray-200">Sessions per weekend:</span>{' '}
<span>{timings.sessionCount}</span>
</div>
<p className="text-xs text-gray-500">
Used for planning and hints; alpha-only and not yet fully wired into
race scheduling.
</p>
</div>
</div>
);
}
const handleNumericMinutesChange = (
field:
| 'practiceMinutes'
| 'qualifyingMinutes'
| 'sprintRaceMinutes'
| 'mainRaceMinutes',
raw: string,
) => {
if (!onChange) return;
const trimmed = raw.trim();
if (trimmed === '') {
updateTimings({ [field]: undefined } as Partial<
NonNullable<LeagueConfigFormModel['timings']>
>);
return;
}
const parsed = parseInt(trimmed, 10);
updateTimings({
[field]:
Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed,
} as Partial<NonNullable<LeagueConfigFormModel['timings']>>);
};
const handleSessionCountChange = (raw: string) => {
const trimmed = raw.trim();
if (trimmed === '') {
updateTimings({ sessionCount: 1 });
return;
}
const parsed = parseInt(trimmed, 10);
updateTimings({
sessionCount: Number.isNaN(parsed) || parsed <= 0 ? 1 : parsed,
});
};
const weekendTemplateValue = weekendTemplate ?? '';
return (
<div className="space-y-6">
<Heading level={3} className="text-lg font-semibold text-white">
{title ?? 'Schedule & timings'}
</Heading>
<div className="space-y-6">
{/* Season length block */}
<section className="space-y-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Season length
</h4>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Planned rounds
</label>
<div className="w-24">
<Input
type="number"
value={
typeof timings.roundsPlanned === 'number'
? String(timings.roundsPlanned)
: ''
}
onChange={(e) => handleRoundsChange(e.target.value)}
min={1}
error={!!errors?.roundsPlanned}
errorMessage={errors?.roundsPlanned}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Used for planning and drop hints; can be approximate.
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Sessions per weekend
</label>
<div className="w-24">
<Input
type="number"
value={String(timings.sessionCount)}
onChange={(e) => handleSessionCountChange(e.target.value)}
min={1}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Typically 1 for feature-only; 2 for sprint + feature.
</p>
</div>
</div>
</section>
{/* Race schedule block */}
<section className="space-y-4">
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Race schedule
</h4>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Season start date
</label>
<div className="max-w-xs">
<Input
type="date"
value={timings.seasonStartDate ?? ''}
onChange={(e) =>
updateTimings({ seasonStartDate: e.target.value || undefined })
}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Round 1 will use this date; later rounds follow your pattern.
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Race start time
</label>
<div className="max-w-xs">
<Input
type="time"
value={timings.raceStartTime ?? ''}
onChange={(e) =>
updateTimings({ raceStartTime: e.target.value || undefined })
}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Local time in your league's timezone.
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Timezone
</label>
<div className="max-w-xs">
<select
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6"
value={timings.timezoneId ?? 'Europe/Berlin'}
onChange={(e) =>
updateTimings({ timezoneId: e.target.value || undefined })
}
>
<option value="Europe/Berlin">Europe/Berlin</option>
<option value="Europe/London">Europe/London</option>
<option value="UTC">UTC</option>
<option value="America/New_York">America/New_York</option>
<option value="America/Los_Angeles">America/Los_Angeles</option>
</select>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] items-start">
<div className="space-y-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Cadence
</label>
<SegmentedControl
options={[
{ value: 'weekly', label: 'Weekly' },
{ value: 'everyNWeeks', label: 'Every N weeks' },
{
value: 'monthlyNthWeekday',
label: 'Monthly (beta)',
disabled: true,
},
]}
value={recurrenceStrategy}
onChange={handleRecurrenceChange}
/>
</div>
{recurrenceStrategy === 'everyNWeeks' && (
<div className="flex items-center gap-2 text-sm text-gray-300">
<span>Every</span>
<div className="w-20">
<Input
type="number"
min={1}
value={
typeof timings.intervalWeeks === 'number'
? String(timings.intervalWeeks)
: '2'
}
onChange={(e) => {
const raw = e.target.value.trim();
if (raw === '') {
updateTimings({ intervalWeeks: undefined });
return;
}
const parsed = parseInt(raw, 10);
updateTimings({
intervalWeeks:
Number.isNaN(parsed) || parsed <= 0 ? 2 : parsed,
});
}}
/>
</div>
<span>weeks</span>
</div>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<label className="block text-sm font-medium text-gray-300">
Race days in a week
</label>
{requiresWeekdaySelection && (
<span className="text-[11px] text-warning-amber">
Select at least one weekday.
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
{(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as Weekday[]).map(
(day) => {
const isActive = weekdays.includes(day);
return (
<button
key={day}
type="button"
onClick={() => handleWeekdayToggle(day)}
className={`px-3 py-1 text-xs font-medium rounded-full border transition-colors ${
isActive
? 'bg-primary-blue text-white border-primary-blue'
: 'bg-iron-gray/80 text-gray-300 border-charcoal-outline hover:bg-charcoal-outline/80 hover:text-white'
}`}
>
{day}
</button>
);
},
)}
</div>
</div>
</div>
<div className="space-y-2 rounded-md border border-charcoal-outline bg-iron-gray/40 p-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-xs font-medium text-gray-200">
Schedule summary
</p>
<p className="text-xs text-gray-400">
{schedulePreview?.summary ??
'Set a start date, time, and at least one weekday to preview the schedule.'}
</p>
</div>
{isSchedulePreviewLoading && (
<span className="text-[11px] text-gray-400">Updating…</span>
)}
</div>
<div className="mt-2 space-y-1">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-gray-300">
Schedule preview
</p>
<p className="text-[11px] text-gray-500">
First few rounds with your current settings.
</p>
</div>
{schedulePreviewError && (
<p className="text-[11px] text-warning-amber">
{schedulePreviewError}
</p>
)}
{!schedulePreview && !schedulePreviewError && (
<p className="text-[11px] text-gray-500">
Adjust the fields above to see a preview of your calendar.
</p>
)}
{schedulePreview && (
<div className="mt-1 space-y-1.5 text-xs text-gray-200">
{schedulePreview.rounds.map((round) => {
const date = new Date(round.scheduledAt);
const dateStr = date.toLocaleDateString(undefined, {
weekday: 'short',
day: 'numeric',
month: 'short',
});
const timeStr = date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
return (
<div
key={round.roundNumber}
className="flex items-center justify-between gap-2"
>
<span className="text-gray-300">
Round {round.roundNumber}
</span>
<span className="text-gray-200">
{dateStr}, {timeStr}{' '}
<span className="text-gray-500">
{round.timezoneId}
</span>
</span>
</div>
);
})}
{typeof timings.roundsPlanned === 'number' &&
timings.roundsPlanned > schedulePreview.rounds.length && (
<p className="pt-1 text-[11px] text-gray-500">
+
{timings.roundsPlanned - schedulePreview.rounds.length}{' '}
more rounds scheduled.
</p>
)}
</div>
)}
</div>
</div>
</section>
{/* Weekend template block */}
<section className="space-y-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Weekend template
</h4>
<p className="text-xs text-gray-500">
Pick a typical weekend; you can fine-tune durations below.
</p>
<SegmentedControl
options={[
{ value: 'feature', label: 'Feature only' },
{ value: 'sprintFeature', label: 'Sprint + feature' },
{ value: 'endurance', label: 'Endurance' },
]}
value={weekendTemplateValue}
onChange={onWeekendTemplateChange}
/>
<p className="text-[11px] text-gray-500">
Templates set starting values only; you can override any number.
</p>
</section>
{/* Session durations block */}
<section className="space-y-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Session durations
</h4>
<p className="text-xs text-gray-500">
Rough lengths for each session type; used for planning only.
</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Practice duration (optional)
</label>
<div className="w-24">
<Input
type="number"
value={
typeof timings.practiceMinutes === 'number' &&
timings.practiceMinutes > 0
? String(timings.practiceMinutes)
: ''
}
onChange={(e) =>
handleNumericMinutesChange(
'practiceMinutes',
e.target.value,
)
}
min={0}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Set to 0 or leave empty if you dont plan dedicated practice.
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Qualifying duration
</label>
<div className="w-24">
<Input
type="number"
value={
typeof timings.qualifyingMinutes === 'number' &&
timings.qualifyingMinutes > 0
? String(timings.qualifyingMinutes)
: ''
}
onChange={(e) =>
handleNumericMinutesChange(
'qualifyingMinutes',
e.target.value,
)
}
min={5}
error={!!errors?.qualifyingMinutes}
errorMessage={errors?.qualifyingMinutes}
/>
</div>
</div>
{showSprint && (
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Sprint duration
</label>
<div className="w-24">
<Input
type="number"
value={
typeof timings.sprintRaceMinutes === 'number' &&
timings.sprintRaceMinutes > 0
? String(timings.sprintRaceMinutes)
: ''
}
onChange={(e) =>
handleNumericMinutesChange(
'sprintRaceMinutes',
e.target.value,
)
}
min={0}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Only shown when your scoring pattern includes a sprint race.
</p>
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Main race duration
</label>
<div className="w-24">
<Input
type="number"
value={
typeof timings.mainRaceMinutes === 'number' &&
timings.mainRaceMinutes > 0
? String(timings.mainRaceMinutes)
: ''
}
onChange={(e) =>
handleNumericMinutesChange(
'mainRaceMinutes',
e.target.value,
)
}
min={10}
error={!!errors?.mainRaceMinutes}
errorMessage={errors?.mainRaceMinutes}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Approximate length of your main race.
</p>
</div>
</div>
</section>
</div>
</div>
);
}

View File

@@ -9,7 +9,7 @@ interface CardProps {
export default function Card({ children, className = '', onClick }: CardProps) {
return (
<div
className={`rounded-lg bg-iron-gray p-6 shadow-card border border-charcoal-outline hover:shadow-glow transition-shadow duration-200 ${className}`}
className={`rounded-lg bg-iron-gray p-6 shadow-card border border-charcoal-outline duration-200 ${className}`}
onClick={onClick}
>
{children}

View File

@@ -0,0 +1,71 @@
'use client';
import Input from '@/components/ui/Input';
interface DurationFieldProps {
label: string;
value: number | '';
onChange: (value: number | '') => void;
helperText?: string;
required?: boolean;
disabled?: boolean;
unit?: 'minutes' | 'laps';
error?: string;
}
export default function DurationField({
label,
value,
onChange,
helperText,
required,
disabled,
unit = 'minutes',
error,
}: DurationFieldProps) {
const handleChange = (raw: string) => {
if (raw.trim() === '') {
onChange('');
return;
}
const parsed = parseInt(raw, 10);
if (Number.isNaN(parsed) || parsed <= 0) {
onChange('');
return;
}
onChange(parsed);
};
const unitLabel = unit === 'laps' ? 'laps' : 'min';
return (
<div className="space-y-1">
<label className="block text-sm font-medium text-gray-300">
{label}
{required && <span className="text-warning-amber ml-1">*</span>}
</label>
<div className="flex items-center gap-2">
<div className="flex-1">
<Input
type="number"
value={value === '' ? '' : String(value)}
onChange={(e) => handleChange(e.target.value)}
disabled={disabled}
min={1}
className="pr-16"
error={!!error}
/>
</div>
<span className="text-xs text-gray-400 -ml-14">{unitLabel}</span>
</div>
{helperText && (
<p className="text-xs text-gray-500">{helperText}</p>
)}
{error && (
<p className="text-xs text-warning-amber mt-1">{error}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
import type { MouseEventHandler, ReactNode } from 'react';
import Card from './Card';
interface PresetCardStat {
label: string;
value: string;
}
export interface PresetCardProps {
title: string;
subtitle?: string;
primaryTag?: string;
description?: string;
stats?: PresetCardStat[];
selected?: boolean;
disabled?: boolean;
onSelect?: () => void;
className?: string;
children?: ReactNode;
}
export default function PresetCard({
title,
subtitle,
primaryTag,
description,
stats,
selected,
disabled,
onSelect,
className = '',
children,
}: PresetCardProps) {
const isInteractive = typeof onSelect === 'function' && !disabled;
const handleClick: MouseEventHandler<HTMLButtonElement | HTMLDivElement> = (event) => {
if (!isInteractive) {
return;
}
event.preventDefault();
onSelect?.();
};
const baseBorder = selected ? 'border-primary-blue' : 'border-charcoal-outline';
const baseBg = selected ? 'bg-primary-blue/10' : 'bg-iron-gray';
const baseRing = selected ? 'ring-2 ring-primary-blue/40' : '';
const disabledClasses = disabled ? 'opacity-60 cursor-not-allowed' : '';
const hoverClasses = isInteractive && !disabled ? 'hover:bg-iron-gray/80 hover:scale-[1.01]' : '';
const content = (
<div className="flex h-full flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-white">{title}</div>
{subtitle && (
<div className="mt-0.5 text-xs text-gray-400">{subtitle}</div>
)}
</div>
<div className="flex flex-col items-end gap-1">
{primaryTag && (
<span className="inline-flex rounded-full bg-primary-blue/15 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary-blue">
{primaryTag}
</span>
)}
{selected && (
<span className="inline-flex items-center gap-1 rounded-full bg-primary-blue/10 px-2 py-0.5 text-[10px] font-medium text-primary-blue">
<span className="h-1.5 w-1.5 rounded-full bg-primary-blue" />
Selected
</span>
)}
</div>
</div>
{description && (
<p className="text-xs text-gray-300">{description}</p>
)}
{children}
{stats && stats.length > 0 && (
<div className="mt-1 border-t border-charcoal-outline/70 pt-2">
<dl className="grid grid-cols-1 gap-2 text-[11px] text-gray-400 sm:grid-cols-3">
{stats.map((stat) => (
<div key={stat.label} className="space-y-0.5">
<dt className="font-medium text-gray-500">{stat.label}</dt>
<dd className="text-xs text-gray-200">{stat.value}</dd>
</div>
))}
</dl>
</div>
)}
</div>
);
const commonClasses = `${baseBorder} ${baseBg} ${baseRing} ${hoverClasses} ${disabledClasses} ${className}`;
if (isInteractive) {
return (
<button
type="button"
onClick={handleClick as MouseEventHandler<HTMLButtonElement>}
disabled={disabled}
className={`group block w-full rounded-lg text-left text-sm shadow-card outline-none transition-all duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-blue ${commonClasses}`}
>
<div className="p-4">
{content}
</div>
</button>
);
}
return (
<Card
className={commonClasses}
onClick={handleClick as MouseEventHandler<HTMLDivElement>}
>
{content}
</Card>
);
}

View File

@@ -0,0 +1,130 @@
'use client';
import Input from '@/components/ui/Input';
interface RangeFieldProps {
label: string;
value: number;
min: number;
max: number;
step?: number;
onChange: (value: number) => void;
helperText?: string;
error?: string;
disabled?: boolean;
/**
* Optional unit label, defaults to "min".
*/
unitLabel?: string;
/**
* Optional override for the right-hand range hint.
*/
rangeHint?: string;
}
export default function RangeField({
label,
value,
min,
max,
step = 1,
onChange,
helperText,
error,
disabled,
unitLabel = 'min',
rangeHint,
}: RangeFieldProps) {
const clampedValue = Number.isFinite(value)
? Math.min(Math.max(value, min), max)
: min;
const handleSliderChange = (raw: string) => {
const parsed = parseInt(raw, 10);
if (Number.isNaN(parsed)) {
return;
}
const next = Math.min(Math.max(parsed, min), max);
onChange(next);
};
const handleNumberChange = (raw: string) => {
if (raw.trim() === '') {
// Allow the field to clear without jumping the slider;
// keep the previous value until the user types a number.
return;
}
const parsed = parseInt(raw, 10);
if (Number.isNaN(parsed)) {
return;
}
const next = Math.min(Math.max(parsed, min), max);
onChange(next);
};
const rangePercent =
((clampedValue - min) / Math.max(max - min, 1)) * 100;
const effectiveRangeHint =
rangeHint ??
(min === 0
? `Up to ${max} ${unitLabel}`
: `${min}${max} ${unitLabel}`);
return (
<div className="space-y-2">
<div className="flex items-baseline justify-between gap-2">
<label className="block text-sm font-medium text-gray-300">
{label}
</label>
<p className="text-[11px] text-gray-500">
{effectiveRangeHint}
</p>
</div>
<div className="space-y-2">
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 right-0 rounded-full bg-charcoal-outline/60" />
<div
className="pointer-events-none absolute inset-y-0 left-0 rounded-full bg-primary-blue"
style={{ width: `${rangePercent}%` }}
/>
<input
type="range"
min={min}
max={max}
step={step}
value={clampedValue}
onChange={(e) => handleSliderChange(e.target.value)}
disabled={disabled}
className="relative z-10 h-2 w-full appearance-none bg-transparent focus:outline-none accent-primary-blue"
/>
</div>
<div className="flex items-center gap-2">
<div className="max-w-[96px]">
<Input
type="number"
value={Number.isFinite(value) ? String(clampedValue) : ''}
onChange={(e) => handleNumberChange(e.target.value)}
min={min}
max={max}
step={step}
disabled={disabled}
className="px-3 py-2 text-sm"
error={!!error}
/>
</div>
<span className="text-xs text-gray-400">{unitLabel}</span>
</div>
</div>
{helperText && (
<p className="text-xs text-gray-500">{helperText}</p>
)}
{error && (
<p className="text-xs text-warning-amber mt-1">{error}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import { ButtonHTMLAttributes } from 'react';
interface SegmentedControlOption {
value: string;
label: string;
description?: string;
disabled?: boolean;
}
interface SegmentedControlProps {
options: SegmentedControlOption[];
value: string;
onChange?: (value: string) => void;
}
export default function SegmentedControl({
options,
value,
onChange,
}: SegmentedControlProps) {
const handleSelect = (optionValue: string, optionDisabled?: boolean) => {
if (!onChange || optionDisabled) return;
if (optionValue === value) return;
onChange(optionValue);
};
return (
<div className="inline-flex w-full flex-wrap gap-2 rounded-full bg-iron-gray/60 px-1 py-1">
{options.map((option) => {
const isSelected = option.value === value;
const baseClasses =
'flex-1 min-w-[140px] px-3 py-1.5 text-xs font-medium rounded-full transition-colors text-left';
const selectedClasses = isSelected
? 'bg-primary-blue text-white'
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80';
const disabledClasses = option.disabled
? 'opacity-50 cursor-not-allowed hover:bg-transparent hover:text-gray-300'
: '';
const buttonProps: ButtonHTMLAttributes<HTMLButtonElement> = {
type: 'button',
onClick: () => handleSelect(option.value, option.disabled),
'aria-pressed': isSelected,
disabled: option.disabled,
};
return (
<button
key={option.value}
{...buttonProps}
className={`${baseClasses} ${selectedClasses} ${disabledClasses}`}
>
<div className="flex flex-col items-start">
<span>{option.label}</span>
{option.description && (
<span className="mt-0.5 text-[10px] text-gray-400">
{option.description}
</span>
)}
</div>
</button>
);
})}
</div>
);
}

View File

@@ -10,6 +10,8 @@ import { League } from '@gridpilot/racing/domain/entities/League';
import { Race } from '@gridpilot/racing/domain/entities/Race';
import { Result } from '@gridpilot/racing/domain/entities/Result';
import { Standing } from '@gridpilot/racing/domain/entities/Standing';
import { Game } from '@gridpilot/racing/domain/entities/Game';
import { Season } from '@gridpilot/racing/domain/entities/Season';
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
@@ -17,6 +19,9 @@ import type { IRaceRepository } from '@gridpilot/racing/domain/repositories/IRac
import type { IResultRepository } from '@gridpilot/racing/domain/repositories/IResultRepository';
import type { IStandingRepository } from '@gridpilot/racing/domain/repositories/IStandingRepository';
import type { IPenaltyRepository } from '@gridpilot/racing/domain/repositories/IPenaltyRepository';
import type { IGameRepository } from '@gridpilot/racing/domain/repositories/IGameRepository';
import type { ISeasonRepository } from '@gridpilot/racing/domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '@gridpilot/racing/domain/repositories/ILeagueScoringConfigRepository';
import type {
ITeamRepository,
ITeamMembershipRepository,
@@ -34,6 +39,13 @@ import { InMemoryRaceRepository } from '@gridpilot/racing/infrastructure/reposit
import { InMemoryResultRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryResultRepository';
import { InMemoryStandingRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryStandingRepository';
import { InMemoryPenaltyRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryPenaltyRepository';
import {
InMemoryGameRepository,
InMemorySeasonRepository,
InMemoryLeagueScoringConfigRepository,
getLeagueScoringPresetById,
} from '@gridpilot/racing/infrastructure/repositories/InMemoryScoringRepositories';
import { InMemoryLeagueScoringPresetProvider } from '@gridpilot/racing/infrastructure/repositories/InMemoryLeagueScoringPresetProvider';
import { InMemoryTeamRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTeamRepository';
import { InMemoryTeamMembershipRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTeamMembershipRepository';
import { InMemoryRaceRegistrationRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRegistrationRepository';
@@ -58,13 +70,28 @@ import {
GetLeagueStandingsQuery,
GetLeagueDriverSeasonStatsQuery,
GetAllLeaguesWithCapacityQuery,
GetAllLeaguesWithCapacityAndScoringQuery,
ListLeagueScoringPresetsQuery,
GetLeagueScoringConfigQuery,
CreateLeagueWithSeasonAndScoringUseCase,
GetLeagueFullConfigQuery,
} from '@gridpilot/racing/application';
import { createStaticRacingSeed, type RacingSeedData } from '@gridpilot/testing-support';
import {
createStaticRacingSeed,
type RacingSeedData,
getDemoLeagueArchetypeByName,
} from '@gridpilot/testing-support';
import type {
LeagueScheduleDTO,
LeagueSchedulePreviewDTO,
} from '@gridpilot/racing/application';
import { PreviewLeagueScheduleQuery } from '@gridpilot/racing/application';
import {
InMemoryFeedRepository,
InMemorySocialGraphRepository,
} from '@gridpilot/social/infrastructure/inmemory/InMemorySocialAndFeed';
import { DemoImageServiceAdapter } from '@gridpilot/demo-infrastructure';
import type { LeagueScoringPresetProvider } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
/**
* Seed data for development
@@ -138,6 +165,10 @@ class DIContainer {
private _teamMembershipRepository: ITeamMembershipRepository;
private _raceRegistrationRepository: IRaceRegistrationRepository;
private _leagueMembershipRepository: ILeagueMembershipRepository;
private _gameRepository: IGameRepository;
private _seasonRepository: ISeasonRepository;
private _leagueScoringConfigRepository: ILeagueScoringConfigRepository;
private _leagueScoringPresetProvider: LeagueScoringPresetProvider;
private _feedRepository: IFeedRepository;
private _socialRepository: ISocialGraphRepository;
private _imageService: ImageServicePort;
@@ -151,6 +182,13 @@ class DIContainer {
private _getLeagueStandingsQuery: GetLeagueStandingsQuery;
private _getLeagueDriverSeasonStatsQuery: GetLeagueDriverSeasonStatsQuery;
private _getAllLeaguesWithCapacityQuery: GetAllLeaguesWithCapacityQuery;
private _getAllLeaguesWithCapacityAndScoringQuery: GetAllLeaguesWithCapacityAndScoringQuery;
private _listLeagueScoringPresetsQuery: ListLeagueScoringPresetsQuery;
private _getLeagueScoringConfigQuery: GetLeagueScoringConfigQuery;
private _createLeagueWithSeasonAndScoringUseCase: CreateLeagueWithSeasonAndScoringUseCase;
private _getLeagueFullConfigQuery: GetLeagueFullConfigQuery;
// Placeholder for future schedule preview wiring
private _previewLeagueScheduleQuery: PreviewLeagueScheduleQuery;
private _createTeamUseCase: CreateTeamUseCase;
private _joinTeamUseCase: JoinTeamUseCase;
@@ -190,10 +228,51 @@ class DIContainer {
// Race registrations (start empty; populated via use-cases)
this._raceRegistrationRepository = new InMemoryRaceRegistrationRepository();
// Penalties (seeded in-memory adapter)
this._penaltyRepository = new InMemoryPenaltyRepository();
// Scoring preset provider and seeded game/season/scoring config repositories
this._leagueScoringPresetProvider = new InMemoryLeagueScoringPresetProvider();
const game = Game.create({ id: 'iracing', name: 'iRacing' });
const seededSeasons: Season[] = [];
const seededScoringConfigs = [];
for (const league of seedData.leagues) {
const archetype = getDemoLeagueArchetypeByName(league.name);
if (!archetype) continue;
const season = Season.create({
id: `season-${league.id}-demo`,
leagueId: league.id,
gameId: game.id,
name: `${league.name} Demo Season`,
year: new Date().getFullYear(),
order: 1,
status: 'active',
startDate: new Date(),
endDate: new Date(),
});
seededSeasons.push(season);
const infraPreset = getLeagueScoringPresetById(
archetype.scoringPresetId,
);
if (!infraPreset) {
// If a preset is missing, skip scoring config for this league in alpha seed.
continue;
}
const config = infraPreset.createConfig({ seasonId: season.id });
seededScoringConfigs.push(config);
}
this._gameRepository = new InMemoryGameRepository([game]);
this._seasonRepository = new InMemorySeasonRepository(seededSeasons);
this._leagueScoringConfigRepository =
new InMemoryLeagueScoringConfigRepository(seededScoringConfigs);
// League memberships seeded from static memberships with guaranteed owner roles
const seededMemberships: LeagueMembership[] = seedData.memberships.map((m) => ({
leagueId: m.leagueId,
@@ -371,6 +450,46 @@ class DIContainer {
this._leagueMembershipRepository,
);
this._getAllLeaguesWithCapacityAndScoringQuery =
new GetAllLeaguesWithCapacityAndScoringQuery(
this._leagueRepository,
this._leagueMembershipRepository,
this._seasonRepository,
this._leagueScoringConfigRepository,
this._gameRepository,
this._leagueScoringPresetProvider,
);
this._listLeagueScoringPresetsQuery = new ListLeagueScoringPresetsQuery(
this._leagueScoringPresetProvider,
);
this._getLeagueScoringConfigQuery = new GetLeagueScoringConfigQuery(
this._leagueRepository,
this._seasonRepository,
this._leagueScoringConfigRepository,
this._gameRepository,
this._leagueScoringPresetProvider,
);
this._getLeagueFullConfigQuery = new GetLeagueFullConfigQuery(
this._leagueRepository,
this._seasonRepository,
this._leagueScoringConfigRepository,
this._gameRepository,
);
this._createLeagueWithSeasonAndScoringUseCase =
new CreateLeagueWithSeasonAndScoringUseCase(
this._leagueRepository,
this._seasonRepository,
this._leagueScoringConfigRepository,
this._leagueScoringPresetProvider,
);
// Schedule preview query (used by league creation wizard step 3)
this._previewLeagueScheduleQuery = new PreviewLeagueScheduleQuery();
this._createTeamUseCase = new CreateTeamUseCase(
this._teamRepository,
this._teamMembershipRepository,
@@ -464,6 +583,22 @@ class DIContainer {
return this._leagueMembershipRepository;
}
get gameRepository(): IGameRepository {
return this._gameRepository;
}
get seasonRepository(): ISeasonRepository {
return this._seasonRepository;
}
get leagueScoringConfigRepository(): ILeagueScoringConfigRepository {
return this._leagueScoringConfigRepository;
}
get leagueScoringPresetProvider(): LeagueScoringPresetProvider {
return this._leagueScoringPresetProvider;
}
get joinLeagueUseCase(): JoinLeagueUseCase {
return this._joinLeagueUseCase;
}
@@ -496,6 +631,31 @@ class DIContainer {
return this._getAllLeaguesWithCapacityQuery;
}
get getAllLeaguesWithCapacityAndScoringQuery(): GetAllLeaguesWithCapacityAndScoringQuery {
return this._getAllLeaguesWithCapacityAndScoringQuery;
}
get listLeagueScoringPresetsQuery(): ListLeagueScoringPresetsQuery {
return this._listLeagueScoringPresetsQuery;
}
get getLeagueScoringConfigQuery(): GetLeagueScoringConfigQuery {
return this._getLeagueScoringConfigQuery;
}
get getLeagueFullConfigQuery(): GetLeagueFullConfigQuery {
return this._getLeagueFullConfigQuery;
}
// Placeholder accessor for schedule preview; API route/UI can call this later.
get previewLeagueScheduleQuery(): PreviewLeagueScheduleQuery {
return this._previewLeagueScheduleQuery;
}
get createLeagueWithSeasonAndScoringUseCase(): CreateLeagueWithSeasonAndScoringUseCase {
return this._createLeagueWithSeasonAndScoringUseCase;
}
get createTeamUseCase(): CreateTeamUseCase {
return this._createTeamUseCase;
}
@@ -628,6 +788,31 @@ export function getGetAllLeaguesWithCapacityQuery(): GetAllLeaguesWithCapacityQu
return DIContainer.getInstance().getAllLeaguesWithCapacityQuery;
}
export function getGetAllLeaguesWithCapacityAndScoringQuery(): GetAllLeaguesWithCapacityAndScoringQuery {
return DIContainer.getInstance().getAllLeaguesWithCapacityAndScoringQuery;
}
export function getGetLeagueScoringConfigQuery(): GetLeagueScoringConfigQuery {
return DIContainer.getInstance().getLeagueScoringConfigQuery;
}
export function getGetLeagueFullConfigQuery(): GetLeagueFullConfigQuery {
return DIContainer.getInstance().getLeagueFullConfigQuery;
}
// Placeholder export for future schedule preview API wiring.
export function getPreviewLeagueScheduleQuery(): PreviewLeagueScheduleQuery {
return DIContainer.getInstance().previewLeagueScheduleQuery;
}
export function getListLeagueScoringPresetsQuery(): ListLeagueScoringPresetsQuery {
return DIContainer.getInstance().listLeagueScoringPresetsQuery;
}
export function getCreateLeagueWithSeasonAndScoringUseCase(): CreateLeagueWithSeasonAndScoringUseCase {
return DIContainer.getInstance().createLeagueWithSeasonAndScoringUseCase;
}
export function getTeamRepository(): ITeamRepository {
return DIContainer.getInstance().teamRepository;
}