wip
This commit is contained in:
94
apps/website/app/api/leagues/schedule-preview/route.ts
Normal file
94
apps/website/app/api/leagues/schedule-preview/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
15
apps/website/app/leagues/create/page.tsx
Normal file
15
apps/website/app/leagues/create/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
587
apps/website/components/leagues/CreateLeagueWizard.tsx
Normal file
587
apps/website/components/leagues/CreateLeagueWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
130
apps/website/components/leagues/LeagueBasicsSection.tsx
Normal file
130
apps/website/components/leagues/LeagueBasicsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
137
apps/website/components/leagues/LeagueDropSection.tsx
Normal file
137
apps/website/components/leagues/LeagueDropSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
apps/website/components/leagues/LeagueReviewSummary.tsx
Normal file
210
apps/website/components/leagues/LeagueReviewSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
489
apps/website/components/leagues/LeagueScoringSection.tsx
Normal file
489
apps/website/components/leagues/LeagueScoringSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
apps/website/components/leagues/LeagueScoringTab.tsx
Normal file
190
apps/website/components/leagues/LeagueScoringTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
246
apps/website/components/leagues/LeagueStructureSection.tsx
Normal file
246
apps/website/components/leagues/LeagueStructureSection.tsx
Normal 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 & 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 20–30
|
||||
</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 2–3 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>
|
||||
);
|
||||
}
|
||||
751
apps/website/components/leagues/LeagueTimingsSection.tsx
Normal file
751
apps/website/components/leagues/LeagueTimingsSection.tsx
Normal 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 don’t 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
71
apps/website/components/ui/DurationField.tsx
Normal file
71
apps/website/components/ui/DurationField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
apps/website/components/ui/PresetCard.tsx
Normal file
122
apps/website/components/ui/PresetCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
apps/website/components/ui/RangeField.tsx
Normal file
130
apps/website/components/ui/RangeField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
apps/website/components/ui/SegmentedControl.tsx
Normal file
68
apps/website/components/ui/SegmentedControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user