wip
This commit is contained in:
@@ -5,11 +5,12 @@ import CreateLeagueWizard from '@/components/leagues/CreateLeagueWizard';
|
|||||||
import Section from '@/components/ui/Section';
|
import Section from '@/components/ui/Section';
|
||||||
import Container from '@/components/ui/Container';
|
import Container from '@/components/ui/Container';
|
||||||
|
|
||||||
type StepName = 'basics' | 'structure' | 'schedule' | 'scoring' | 'review';
|
type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'review';
|
||||||
|
|
||||||
function normalizeStepName(raw: string | null): StepName {
|
function normalizeStepName(raw: string | null): StepName {
|
||||||
switch (raw) {
|
switch (raw) {
|
||||||
case 'basics':
|
case 'basics':
|
||||||
|
case 'visibility':
|
||||||
case 'structure':
|
case 'structure':
|
||||||
case 'schedule':
|
case 'schedule':
|
||||||
case 'scoring':
|
case 'scoring':
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, FormEvent } from 'react';
|
import { useEffect, useState, FormEvent, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
|
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
|
||||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||||
import { LeagueBasicsSection } from './LeagueBasicsSection';
|
import { LeagueBasicsSection } from './LeagueBasicsSection';
|
||||||
|
import { LeagueVisibilitySection } from './LeagueVisibilitySection';
|
||||||
import { LeagueStructureSection } from './LeagueStructureSection';
|
import { LeagueStructureSection } from './LeagueStructureSection';
|
||||||
import {
|
import {
|
||||||
LeagueScoringSection,
|
LeagueScoringSection,
|
||||||
@@ -42,9 +43,65 @@ import {
|
|||||||
import { LeagueDropSection } from './LeagueDropSection';
|
import { LeagueDropSection } from './LeagueDropSection';
|
||||||
import { LeagueTimingsSection } from './LeagueTimingsSection';
|
import { LeagueTimingsSection } from './LeagueTimingsSection';
|
||||||
|
|
||||||
type Step = 1 | 2 | 3 | 4 | 5;
|
// ============================================================================
|
||||||
|
// LOCAL STORAGE PERSISTENCE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
type StepName = 'basics' | 'structure' | 'schedule' | 'scoring' | 'review';
|
const STORAGE_KEY = 'gridpilot_league_wizard_draft';
|
||||||
|
const STORAGE_HIGHEST_STEP_KEY = 'gridpilot_league_wizard_highest_step';
|
||||||
|
|
||||||
|
function saveFormToStorage(form: LeagueConfigFormModel): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors (quota exceeded, etc.)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFormFromStorage(): LeagueConfigFormModel | null {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
return JSON.parse(stored) as LeagueConfigFormModel;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFormStorage(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
localStorage.removeItem(STORAGE_HIGHEST_STEP_KEY);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveHighestStep(step: number): void {
|
||||||
|
try {
|
||||||
|
const current = getHighestStep();
|
||||||
|
if (step > current) {
|
||||||
|
localStorage.setItem(STORAGE_HIGHEST_STEP_KEY, String(step));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHighestStep(): number {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_HIGHEST_STEP_KEY);
|
||||||
|
return stored ? parseInt(stored, 10) : 1;
|
||||||
|
} catch {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = 1 | 2 | 3 | 4 | 5 | 6;
|
||||||
|
|
||||||
|
type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'review';
|
||||||
|
|
||||||
interface CreateLeagueWizardProps {
|
interface CreateLeagueWizardProps {
|
||||||
stepName: StepName;
|
stepName: StepName;
|
||||||
@@ -55,14 +112,16 @@ function stepNameToStep(stepName: StepName): Step {
|
|||||||
switch (stepName) {
|
switch (stepName) {
|
||||||
case 'basics':
|
case 'basics':
|
||||||
return 1;
|
return 1;
|
||||||
case 'structure':
|
case 'visibility':
|
||||||
return 2;
|
return 2;
|
||||||
case 'schedule':
|
case 'structure':
|
||||||
return 3;
|
return 3;
|
||||||
case 'scoring':
|
case 'schedule':
|
||||||
return 4;
|
return 4;
|
||||||
case 'review':
|
case 'scoring':
|
||||||
return 5;
|
return 5;
|
||||||
|
case 'review':
|
||||||
|
return 6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,18 +130,29 @@ function stepToStepName(step: Step): StepName {
|
|||||||
case 1:
|
case 1:
|
||||||
return 'basics';
|
return 'basics';
|
||||||
case 2:
|
case 2:
|
||||||
return 'structure';
|
return 'visibility';
|
||||||
case 3:
|
case 3:
|
||||||
return 'schedule';
|
return 'structure';
|
||||||
case 4:
|
case 4:
|
||||||
return 'scoring';
|
return 'schedule';
|
||||||
case 5:
|
case 5:
|
||||||
|
return 'scoring';
|
||||||
|
case 6:
|
||||||
return 'review';
|
return 'review';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
import type { WizardErrors } from '@/lib/leagueWizardService';
|
import type { WizardErrors } from '@/lib/leagueWizardService';
|
||||||
|
|
||||||
|
function getDefaultSeasonStartDate(): string {
|
||||||
|
// Default to next Saturday
|
||||||
|
const now = new Date();
|
||||||
|
const daysUntilSaturday = (6 - now.getDay() + 7) % 7 || 7;
|
||||||
|
const nextSaturday = new Date(now);
|
||||||
|
nextSaturday.setDate(now.getDate() + daysUntilSaturday);
|
||||||
|
return nextSaturday.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
function createDefaultForm(): LeagueConfigFormModel {
|
function createDefaultForm(): LeagueConfigFormModel {
|
||||||
const defaultPatternId = 'sprint-main-driver';
|
const defaultPatternId = 'sprint-main-driver';
|
||||||
|
|
||||||
@@ -121,6 +191,12 @@ function createDefaultForm(): LeagueConfigFormModel {
|
|||||||
mainRaceMinutes: 40,
|
mainRaceMinutes: 40,
|
||||||
sessionCount: 2,
|
sessionCount: 2,
|
||||||
roundsPlanned: 8,
|
roundsPlanned: 8,
|
||||||
|
// Default to Saturday races, weekly, starting next week
|
||||||
|
weekdays: ['Sat'] as import('@gridpilot/racing/domain/value-objects/Weekday').Weekday[],
|
||||||
|
recurrenceStrategy: 'weekly' as const,
|
||||||
|
raceStartTime: '20:00',
|
||||||
|
timezoneId: 'UTC',
|
||||||
|
seasonStartDate: getDefaultSeasonStartDate(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -133,10 +209,39 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
const [presetsLoading, setPresetsLoading] = useState(true);
|
const [presetsLoading, setPresetsLoading] = useState(true);
|
||||||
const [presets, setPresets] = useState<LeagueScoringPresetDTO[]>([]);
|
const [presets, setPresets] = useState<LeagueScoringPresetDTO[]>([]);
|
||||||
const [errors, setErrors] = useState<WizardErrors>({});
|
const [errors, setErrors] = useState<WizardErrors>({});
|
||||||
|
const [highestCompletedStep, setHighestCompletedStep] = useState(1);
|
||||||
|
const [isHydrated, setIsHydrated] = useState(false);
|
||||||
|
|
||||||
|
// Initialize form from localStorage or defaults
|
||||||
const [form, setForm] = useState<LeagueConfigFormModel>(() =>
|
const [form, setForm] = useState<LeagueConfigFormModel>(() =>
|
||||||
createDefaultForm(),
|
createDefaultForm(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Hydrate from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = loadFormFromStorage();
|
||||||
|
if (stored) {
|
||||||
|
setForm(stored);
|
||||||
|
}
|
||||||
|
setHighestCompletedStep(getHighestStep());
|
||||||
|
setIsHydrated(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save form to localStorage whenever it changes (after hydration)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isHydrated) {
|
||||||
|
saveFormToStorage(form);
|
||||||
|
}
|
||||||
|
}, [form, isHydrated]);
|
||||||
|
|
||||||
|
// Track highest step reached
|
||||||
|
useEffect(() => {
|
||||||
|
if (isHydrated) {
|
||||||
|
saveHighestStep(step);
|
||||||
|
setHighestCompletedStep((prev) => Math.max(prev, step));
|
||||||
|
}
|
||||||
|
}, [step, isHydrated]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadPresets() {
|
async function loadPresets() {
|
||||||
try {
|
try {
|
||||||
@@ -182,7 +287,9 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
if (!validateStep(step)) {
|
if (!validateStep(step)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextStep = (step < 5 ? ((step + 1) as Step) : step);
|
const nextStep = (step < 6 ? ((step + 1) as Step) : step);
|
||||||
|
saveHighestStep(nextStep);
|
||||||
|
setHighestCompletedStep((prev) => Math.max(prev, nextStep));
|
||||||
onStepChange(stepToStepName(nextStep));
|
onStepChange(stepToStepName(nextStep));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -191,6 +298,13 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
onStepChange(stepToStepName(prevStep));
|
onStepChange(stepToStepName(prevStep));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Navigate to a specific step (only if it's been reached before)
|
||||||
|
const goToStep = useCallback((targetStep: Step) => {
|
||||||
|
if (targetStep <= highestCompletedStep) {
|
||||||
|
onStepChange(stepToStepName(targetStep));
|
||||||
|
}
|
||||||
|
}, [highestCompletedStep, onStepChange]);
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent) => {
|
const handleSubmit = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
@@ -211,6 +325,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await createLeagueFromConfig(form);
|
const result = await createLeagueFromConfig(form);
|
||||||
|
// Clear the draft on successful creation
|
||||||
|
clearFormStorage();
|
||||||
router.push(`/leagues/${result.leagueId}`);
|
router.push(`/leagues/${result.leagueId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message =
|
const message =
|
||||||
@@ -233,10 +349,11 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{ id: 1 as Step, label: 'Basics', icon: FileText, shortLabel: 'Name' },
|
{ id: 1 as Step, label: 'Basics', icon: FileText, shortLabel: 'Name' },
|
||||||
{ id: 2 as Step, label: 'Structure', icon: Users, shortLabel: 'Mode' },
|
{ id: 2 as Step, label: 'Visibility', icon: Award, shortLabel: 'Type' },
|
||||||
{ id: 3 as Step, label: 'Schedule', icon: Calendar, shortLabel: 'Time' },
|
{ id: 3 as Step, label: 'Structure', icon: Users, shortLabel: 'Mode' },
|
||||||
{ id: 4 as Step, label: 'Scoring', icon: Trophy, shortLabel: 'Points' },
|
{ id: 4 as Step, label: 'Schedule', icon: Calendar, shortLabel: 'Time' },
|
||||||
{ id: 5 as Step, label: 'Review', icon: CheckCircle2, shortLabel: 'Done' },
|
{ id: 5 as Step, label: 'Scoring', icon: Trophy, shortLabel: 'Points' },
|
||||||
|
{ id: 6 as Step, label: 'Review', icon: CheckCircle2, shortLabel: 'Done' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const getStepTitle = (currentStep: Step): string => {
|
const getStepTitle = (currentStep: Step): string => {
|
||||||
@@ -244,12 +361,14 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
case 1:
|
case 1:
|
||||||
return 'Name your league';
|
return 'Name your league';
|
||||||
case 2:
|
case 2:
|
||||||
return 'Choose the structure';
|
return 'Choose your destiny';
|
||||||
case 3:
|
case 3:
|
||||||
return 'Set the schedule';
|
return 'Choose the structure';
|
||||||
case 4:
|
case 4:
|
||||||
return 'Scoring & championships';
|
return 'Set the schedule';
|
||||||
case 5:
|
case 5:
|
||||||
|
return 'Scoring & championships';
|
||||||
|
case 6:
|
||||||
return 'Review & create';
|
return 'Review & create';
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
@@ -259,14 +378,16 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
const getStepSubtitle = (currentStep: Step): string => {
|
const getStepSubtitle = (currentStep: Step): string => {
|
||||||
switch (currentStep) {
|
switch (currentStep) {
|
||||||
case 1:
|
case 1:
|
||||||
return 'Give your league a memorable name and choose who can join.';
|
return 'Give your league a memorable name and tell your story.';
|
||||||
case 2:
|
case 2:
|
||||||
return 'Will drivers compete individually or as part of teams?';
|
return 'Will you compete for global rankings or race with friends?';
|
||||||
case 3:
|
case 3:
|
||||||
return 'Configure session durations and plan your season calendar.';
|
return 'Will drivers compete individually or as part of teams?';
|
||||||
case 4:
|
case 4:
|
||||||
return 'Select a scoring preset, enable championships, and set drop rules.';
|
return 'Configure session durations and plan your season calendar.';
|
||||||
case 5:
|
case 5:
|
||||||
|
return 'Select a scoring preset, enable championships, and set drop rules.';
|
||||||
|
case 6:
|
||||||
return 'Everything looks good? Launch your new league!';
|
return 'Everything looks good? Launch your new league!';
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
@@ -310,10 +431,17 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
{steps.map((wizardStep) => {
|
{steps.map((wizardStep) => {
|
||||||
const isCompleted = wizardStep.id < step;
|
const isCompleted = wizardStep.id < step;
|
||||||
const isCurrent = wizardStep.id === step;
|
const isCurrent = wizardStep.id === step;
|
||||||
|
const isAccessible = wizardStep.id <= highestCompletedStep;
|
||||||
const StepIcon = wizardStep.icon;
|
const StepIcon = wizardStep.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={wizardStep.id} className="flex flex-col items-center">
|
<button
|
||||||
|
key={wizardStep.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => goToStep(wizardStep.id)}
|
||||||
|
disabled={!isAccessible}
|
||||||
|
className="flex flex-col items-center bg-transparent border-0 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
relative z-10 flex h-10 w-10 items-center justify-center rounded-full
|
relative z-10 flex h-10 w-10 items-center justify-center rounded-full
|
||||||
@@ -321,8 +449,10 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
${isCurrent
|
${isCurrent
|
||||||
? 'bg-primary-blue text-white shadow-[0_0_24px_rgba(25,140,255,0.5)] scale-110'
|
? 'bg-primary-blue text-white shadow-[0_0_24px_rgba(25,140,255,0.5)] scale-110'
|
||||||
: isCompleted
|
: isCompleted
|
||||||
? 'bg-primary-blue text-white'
|
? 'bg-primary-blue text-white hover:scale-105'
|
||||||
: 'bg-iron-gray text-gray-500 border-2 border-charcoal-outline'
|
: isAccessible
|
||||||
|
? 'bg-iron-gray text-gray-400 border-2 border-charcoal-outline hover:border-primary-blue/50'
|
||||||
|
: 'bg-iron-gray text-gray-500 border-2 border-charcoal-outline opacity-60'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
@@ -339,13 +469,15 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
? 'text-white'
|
? 'text-white'
|
||||||
: isCompleted
|
: isCompleted
|
||||||
? 'text-primary-blue'
|
? 'text-primary-blue'
|
||||||
|
: isAccessible
|
||||||
|
? 'text-gray-400'
|
||||||
: 'text-gray-500'
|
: 'text-gray-500'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{wizardStep.label}
|
{wizardStep.label}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -429,6 +561,16 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<LeagueVisibilitySection
|
||||||
|
form={form}
|
||||||
|
onChange={setForm}
|
||||||
|
errors={errors.basics}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
<LeagueStructureSection
|
<LeagueStructureSection
|
||||||
form={form}
|
form={form}
|
||||||
@@ -438,7 +580,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 3 && (
|
{step === 4 && (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
<LeagueTimingsSection
|
<LeagueTimingsSection
|
||||||
form={form}
|
form={form}
|
||||||
@@ -448,7 +590,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 4 && (
|
{step === 5 && (
|
||||||
<div className="animate-fade-in space-y-8">
|
<div className="animate-fade-in space-y-8">
|
||||||
{/* Scoring Pattern Selection */}
|
{/* Scoring Pattern Selection */}
|
||||||
<ScoringPatternSection
|
<ScoringPatternSection
|
||||||
@@ -486,7 +628,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 5 && (
|
{step === 6 && (
|
||||||
<div className="animate-fade-in space-y-6">
|
<div className="animate-fade-in space-y-6">
|
||||||
<LeagueReviewSummary form={form} presets={presets} />
|
<LeagueReviewSummary form={form} presets={presets} />
|
||||||
{errors.submit && (
|
{errors.submit && (
|
||||||
@@ -527,7 +669,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{step < 5 ? (
|
{step < 6 ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { FileText, Globe, Lock, Gamepad2 } from 'lucide-react';
|
import { FileText, Gamepad2, AlertCircle, Check } from 'lucide-react';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import type {
|
import type {
|
||||||
LeagueConfigFormModel,
|
LeagueConfigFormModel,
|
||||||
@@ -11,7 +11,7 @@ interface LeagueBasicsSectionProps {
|
|||||||
onChange?: (form: LeagueConfigFormModel) => void;
|
onChange?: (form: LeagueConfigFormModel) => void;
|
||||||
errors?: {
|
errors?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
visibility?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
@@ -37,9 +37,19 @@ export function LeagueBasicsSection({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
|
{/* Emotional header for the step */}
|
||||||
|
<div className="text-center pb-2">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">
|
||||||
|
Every great championship starts with a name
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-400 max-w-lg mx-auto">
|
||||||
|
This is where legends begin. Give your league an identity that drivers will remember.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* League name */}
|
{/* League name */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
|
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
|
||||||
<FileText className="w-4 h-4 text-primary-blue" />
|
<FileText className="w-4 h-4 text-primary-blue" />
|
||||||
League name *
|
League name *
|
||||||
@@ -53,37 +63,45 @@ export function LeagueBasicsSection({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
Choose a clear, memorable name that describes your league
|
Make it memorable — this is what drivers will see first
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="text-xs text-gray-500">Try:</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateBasics({ name: 'Weekly Sprint Championship' })}
|
onClick={() => updateBasics({ name: 'Sunday Showdown Series' })}
|
||||||
className="text-xs text-primary-blue hover:text-primary-blue/80 transition-colors"
|
className="text-xs px-2 py-0.5 rounded-full bg-primary-blue/10 text-primary-blue hover:bg-primary-blue/20 transition-colors"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
Example: Weekly Sprint Championship
|
Sunday Showdown Series
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-gray-600">•</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateBasics({ name: 'Sunday Evening Endurance' })}
|
onClick={() => updateBasics({ name: 'Midnight Endurance League' })}
|
||||||
className="text-xs text-primary-blue hover:text-primary-blue/80 transition-colors"
|
className="text-xs px-2 py-0.5 rounded-full bg-primary-blue/10 text-primary-blue hover:bg-primary-blue/20 transition-colors"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
Example: Sunday Evening Endurance
|
Midnight Endurance League
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateBasics({ name: 'GT Masters Championship' })}
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full bg-primary-blue/10 text-primary-blue hover:bg-primary-blue/20 transition-colors"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
GT Masters Championship
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description - Now Required */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
|
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
|
||||||
<FileText className="w-4 h-4 text-gray-400" />
|
<FileText className="w-4 h-4 text-primary-blue" />
|
||||||
Description (optional)
|
Tell your story *
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={basics.description ?? ''}
|
value={basics.description ?? ''}
|
||||||
@@ -94,87 +112,48 @@ export function LeagueBasicsSection({
|
|||||||
}
|
}
|
||||||
rows={4}
|
rows={4}
|
||||||
disabled={disabled}
|
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 transition-all duration-150"
|
className={`block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue text-sm disabled:opacity-60 disabled:cursor-not-allowed transition-all duration-150 ${
|
||||||
placeholder="Example: A competitive sprint racing series held every Sunday at 19:00 CET. We focus on close racing and fair play. All skill levels welcome!"
|
errors?.description ? 'ring-warning-amber' : 'ring-charcoal-outline'
|
||||||
|
}`}
|
||||||
|
placeholder="What makes your league special? Tell drivers what to expect..."
|
||||||
/>
|
/>
|
||||||
<div className="space-y-1">
|
{errors?.description && (
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-warning-amber flex items-center gap-1.5">
|
||||||
Help potential members understand your league's style, schedule, and community
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
{errors.description}
|
||||||
</p>
|
</p>
|
||||||
<div className="text-xs text-gray-500">
|
)}
|
||||||
<span className="font-medium text-gray-400">Tip:</span> Mention your racing style, typical schedule, and skill level expectations
|
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline/50 p-4 space-y-3">
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
<span className="font-medium text-gray-300">Great descriptions include:</span>
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
|
||||||
|
<span className="text-xs text-gray-400">Racing style & pace</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
|
||||||
|
<span className="text-xs text-gray-400">Schedule & timezone</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
|
||||||
|
<span className="text-xs text-gray-400">Community vibe</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
{/* Game Platform */}
|
||||||
{/* Visibility */}
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
|
<Gamepad2 className="w-4 h-4 text-gray-400" />
|
||||||
<Globe className="w-4 h-4 text-primary-blue" />
|
Game platform
|
||||||
Visibility *
|
</label>
|
||||||
</label>
|
<div className="relative">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<Input value="iRacing" disabled />
|
||||||
<button
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-500">
|
||||||
type="button"
|
More platforms soon
|
||||||
disabled={disabled}
|
|
||||||
onClick={() =>
|
|
||||||
updateBasics({
|
|
||||||
visibility: 'public',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className={`group relative flex flex-col items-center gap-2 px-4 py-4 text-sm rounded-lg border transition-all duration-200 ${
|
|
||||||
basics.visibility === 'public'
|
|
||||||
? 'border-primary-blue bg-primary-blue/10 text-primary-blue shadow-[0_0_15px_rgba(25,140,255,0.2)]'
|
|
||||||
: 'border-charcoal-outline bg-iron-gray/50 text-gray-300 hover:border-gray-500 hover:bg-iron-gray'
|
|
||||||
} ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
|
|
||||||
>
|
|
||||||
<Globe className={`w-5 h-5 ${basics.visibility === 'public' ? 'text-primary-blue' : 'text-gray-400'}`} />
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="font-medium">Public</div>
|
|
||||||
<div className="text-xs text-gray-500 mt-0.5">Anyone can join</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() =>
|
|
||||||
updateBasics({
|
|
||||||
visibility: 'private',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className={`group relative flex flex-col items-center gap-2 px-4 py-4 text-sm rounded-lg border transition-all duration-200 ${
|
|
||||||
basics.visibility === 'private'
|
|
||||||
? 'border-primary-blue bg-primary-blue/10 text-primary-blue shadow-[0_0_15px_rgba(25,140,255,0.2)]'
|
|
||||||
: 'border-charcoal-outline bg-iron-gray/50 text-gray-300 hover:border-gray-500 hover:bg-iron-gray'
|
|
||||||
} ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
|
|
||||||
>
|
|
||||||
<Lock className={`w-5 h-5 ${basics.visibility === 'private' ? 'text-primary-blue' : 'text-gray-400'}`} />
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="font-medium">Private</div>
|
|
||||||
<div className="text-xs text-gray-500 mt-0.5">Invite only</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{errors?.visibility && (
|
|
||||||
<p className="mt-1 text-xs text-warning-amber flex items-center gap-1">
|
|
||||||
<span>⚠️</span>
|
|
||||||
{errors.visibility}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Game */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
|
|
||||||
<Gamepad2 className="w-4 h-4 text-gray-400" />
|
|
||||||
Game platform
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Input value="iRacing" disabled />
|
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-500">
|
|
||||||
More platforms soon
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,17 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import {
|
||||||
|
Trophy,
|
||||||
|
Users,
|
||||||
|
Flag,
|
||||||
|
Award,
|
||||||
|
Gamepad2,
|
||||||
|
Calendar,
|
||||||
|
ChevronRight,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
import type { LeagueSummaryDTO } from '@gridpilot/racing/application/dto/LeagueSummaryDTO';
|
import type { LeagueSummaryDTO } from '@gridpilot/racing/application/dto/LeagueSummaryDTO';
|
||||||
import Card from '../ui/Card';
|
|
||||||
import { getLeagueCoverClasses } from '@/lib/leagueCovers';
|
import { getLeagueCoverClasses } from '@/lib/leagueCovers';
|
||||||
import { getImageService } from '@/lib/di-container';
|
import { getImageService } from '@/lib/di-container';
|
||||||
|
|
||||||
@@ -12,133 +21,214 @@ interface LeagueCardProps {
|
|||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getChampionshipIcon(type?: string) {
|
||||||
|
switch (type) {
|
||||||
|
case 'driver':
|
||||||
|
return Trophy;
|
||||||
|
case 'team':
|
||||||
|
return Users;
|
||||||
|
case 'nations':
|
||||||
|
return Flag;
|
||||||
|
case 'trophy':
|
||||||
|
return Award;
|
||||||
|
default:
|
||||||
|
return Trophy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChampionshipLabel(type?: string) {
|
||||||
|
switch (type) {
|
||||||
|
case 'driver':
|
||||||
|
return 'Driver';
|
||||||
|
case 'team':
|
||||||
|
return 'Team';
|
||||||
|
case 'nations':
|
||||||
|
return 'Nations';
|
||||||
|
case 'trophy':
|
||||||
|
return 'Trophy';
|
||||||
|
default:
|
||||||
|
return 'Championship';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGameColor(gameId?: string): string {
|
||||||
|
switch (gameId) {
|
||||||
|
case 'iracing':
|
||||||
|
return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
|
||||||
|
case 'acc':
|
||||||
|
return 'bg-green-500/20 text-green-400 border-green-500/30';
|
||||||
|
case 'f1-23':
|
||||||
|
case 'f1-24':
|
||||||
|
return 'bg-red-500/20 text-red-400 border-red-500/30';
|
||||||
|
default:
|
||||||
|
return 'bg-primary-blue/20 text-primary-blue border-primary-blue/30';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNewLeague(createdAt: Date): boolean {
|
||||||
|
const oneWeekAgo = new Date();
|
||||||
|
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
|
||||||
|
return new Date(createdAt) > oneWeekAgo;
|
||||||
|
}
|
||||||
|
|
||||||
export default function LeagueCard({ league, onClick }: LeagueCardProps) {
|
export default function LeagueCard({ league, onClick }: LeagueCardProps) {
|
||||||
const imageService = getImageService();
|
const imageService = getImageService();
|
||||||
const coverUrl = imageService.getLeagueCover(league.id);
|
const coverUrl = imageService.getLeagueCover(league.id);
|
||||||
const logoUrl = imageService.getLeagueLogo(league.id);
|
const logoUrl = imageService.getLeagueLogo(league.id);
|
||||||
|
|
||||||
|
const ChampionshipIcon = getChampionshipIcon(league.scoring?.primaryChampionshipType);
|
||||||
|
const championshipLabel = getChampionshipLabel(league.scoring?.primaryChampionshipType);
|
||||||
|
const gameColorClass = getGameColor(league.scoring?.gameId);
|
||||||
|
const isNew = isNewLeague(league.createdAt);
|
||||||
|
const isTeamLeague = league.maxTeams && league.maxTeams > 0;
|
||||||
|
|
||||||
|
// Calculate fill percentage - use teams for team leagues, drivers otherwise
|
||||||
|
const usedSlots = isTeamLeague ? (league.usedTeamSlots ?? 0) : (league.usedDriverSlots ?? 0);
|
||||||
|
const maxSlots = isTeamLeague ? (league.maxTeams ?? 0) : (league.maxDrivers ?? 0);
|
||||||
|
const fillPercentage = maxSlots > 0 ? (usedSlots / maxSlots) * 100 : 0;
|
||||||
|
const hasOpenSlots = maxSlots > 0 && usedSlots < maxSlots;
|
||||||
|
|
||||||
|
// Determine slot label based on championship type
|
||||||
|
const getSlotLabel = () => {
|
||||||
|
if (isTeamLeague) return 'Teams';
|
||||||
|
if (league.scoring?.primaryChampionshipType === 'nations') return 'Nations';
|
||||||
|
return 'Drivers';
|
||||||
|
};
|
||||||
|
const slotLabel = getSlotLabel();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="cursor-pointer hover:scale-[1.03] transition-transform duration-150"
|
className="group relative cursor-pointer h-full"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<Card>
|
{/* Card Container */}
|
||||||
<div className="space-y-3">
|
<div className="relative h-full rounded-xl bg-iron-gray border border-charcoal-outline overflow-hidden transition-all duration-200 hover:border-primary-blue/50 hover:shadow-[0_0_30px_rgba(25,140,255,0.15)] hover:bg-iron-gray/80">
|
||||||
<div className={getLeagueCoverClasses(league.id)} aria-hidden="true">
|
{/* Cover Image */}
|
||||||
<div className="relative w-full h-full">
|
<div className="relative h-32 overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
src={coverUrl}
|
src={coverUrl}
|
||||||
alt={`${league.name} cover`}
|
alt={`${league.name} cover`}
|
||||||
fill
|
fill
|
||||||
className="object-cover opacity-80"
|
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
|
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-4 bottom-4 flex items-center">
|
{/* Gradient Overlay */}
|
||||||
<div className="w-10 h-10 rounded-full overflow-hidden border border-charcoal-outline/80 bg-deep-graphite/80 shadow-[0_0_10px_rgba(0,0,0,0.6)]">
|
<div className="absolute inset-0 bg-gradient-to-t from-iron-gray via-iron-gray/60 to-transparent" />
|
||||||
<Image
|
|
||||||
src={logoUrl}
|
{/* Badges - Top Left */}
|
||||||
alt={`${league.name} logo`}
|
<div className="absolute top-3 left-3 flex items-center gap-2">
|
||||||
width={40}
|
{isNew && (
|
||||||
height={40}
|
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-performance-green/20 text-performance-green border border-performance-green/30">
|
||||||
className="w-full h-full object-cover"
|
<Sparkles className="w-3 h-3" />
|
||||||
/>
|
NEW
|
||||||
</div>
|
</span>
|
||||||
</div>
|
)}
|
||||||
</div>
|
{league.scoring?.gameName && (
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-[10px] font-semibold border ${gameColorClass}`}>
|
||||||
|
{league.scoring.gameName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start justify-between">
|
{/* Championship Type Badge - Top Right */}
|
||||||
<h3 className="text-xl font-semibold text-white">{league.name}</h3>
|
<div className="absolute top-3 right-3">
|
||||||
<span className="text-xs text-gray-500">
|
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-deep-graphite/80 text-gray-300 border border-charcoal-outline">
|
||||||
{new Date(league.createdAt).toLocaleDateString()}
|
<ChampionshipIcon className="w-3 h-3" />
|
||||||
|
{championshipLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-400 text-sm line-clamp-2">
|
{/* Logo */}
|
||||||
{league.description}
|
<div className="absolute left-4 -bottom-6 z-10">
|
||||||
</p>
|
<div className="w-12 h-12 rounded-lg overflow-hidden border-2 border-iron-gray bg-deep-graphite shadow-xl">
|
||||||
|
<Image
|
||||||
{league.structureSummary && (
|
src={logoUrl}
|
||||||
<p className="text-xs text-gray-400">
|
alt={`${league.name} logo`}
|
||||||
{league.structureSummary}
|
width={48}
|
||||||
</p>
|
height={48}
|
||||||
)}
|
className="w-full h-full object-cover"
|
||||||
{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">
|
|
||||||
<span>
|
|
||||||
Owner:{' '}
|
|
||||||
<Link
|
|
||||||
href={`/drivers/${league.ownerId}?from=league&leagueId=${league.id}`}
|
|
||||||
className="text-primary-blue hover:underline"
|
|
||||||
>
|
|
||||||
{league.ownerId.slice(0, 8)}...
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
<span className="mt-1 text-gray-400">
|
|
||||||
Drivers:{' '}
|
|
||||||
<span className="text-white font-medium">
|
|
||||||
{typeof league.usedDriverSlots === 'number'
|
|
||||||
? league.usedDriverSlots
|
|
||||||
: '—'}
|
|
||||||
</span>
|
|
||||||
{' / '}
|
|
||||||
<span className="text-gray-300">
|
|
||||||
{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="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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="pt-8 px-4 pb-4 flex flex-col flex-1">
|
||||||
|
{/* Title & Description */}
|
||||||
|
<h3 className="text-base font-semibold text-white mb-1 line-clamp-1 group-hover:text-primary-blue transition-colors">
|
||||||
|
{league.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 line-clamp-2 mb-3 h-8">
|
||||||
|
{league.description || 'No description available'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Stats Row */}
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
{/* Primary Slots (Drivers/Teams/Nations) */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center justify-between text-[10px] text-gray-500 mb-1">
|
||||||
|
<span>{slotLabel}</span>
|
||||||
|
<span className="text-gray-400">
|
||||||
|
{usedSlots}/{maxSlots || '∞'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 rounded-full bg-charcoal-outline overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${
|
||||||
|
fillPercentage >= 90
|
||||||
|
? 'bg-warning-amber'
|
||||||
|
: fillPercentage >= 70
|
||||||
|
? 'bg-primary-blue'
|
||||||
|
: 'bg-performance-green'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.min(fillPercentage, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Open Slots Badge */}
|
||||||
|
{hasOpenSlots && (
|
||||||
|
<div className="flex items-center gap-1 px-2 py-1 rounded-lg bg-neon-aqua/10 border border-neon-aqua/20">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-neon-aqua animate-pulse" />
|
||||||
|
<span className="text-[10px] text-neon-aqua font-medium">
|
||||||
|
{maxSlots - usedSlots} open
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Driver count for team leagues */}
|
||||||
|
{isTeamLeague && (
|
||||||
|
<div className="flex items-center gap-2 mb-3 text-[10px] text-gray-500">
|
||||||
|
<Users className="w-3 h-3" />
|
||||||
|
<span>
|
||||||
|
{league.usedDriverSlots ?? 0}/{league.maxDrivers ?? '∞'} drivers
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Spacer to push footer to bottom */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Footer Info */}
|
||||||
|
<div className="flex items-center justify-between pt-3 border-t border-charcoal-outline/50 mt-auto">
|
||||||
|
<div className="flex items-center gap-3 text-[10px] text-gray-500">
|
||||||
|
{league.timingSummary && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{league.timingSummary.split('•')[1]?.trim() || league.timingSummary}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Arrow */}
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-gray-500 group-hover:text-primary-blue transition-colors">
|
||||||
|
<span>View</span>
|
||||||
|
<ChevronRight className="w-3 h-3 transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -362,13 +362,13 @@ export function LeagueDropSection({
|
|||||||
const isSelected = dropPolicy.strategy === option.value;
|
const isSelected = dropPolicy.strategy === option.value;
|
||||||
const ruleInfo = DROP_RULE_INFO[option.value];
|
const ruleInfo = DROP_RULE_INFO[option.value];
|
||||||
return (
|
return (
|
||||||
<div key={option.value} className="relative">
|
<div key={option.value} className="relative flex items-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => handleStrategyChange(option.value)}
|
onClick={() => handleStrategyChange(option.value)}
|
||||||
className={`
|
className={`
|
||||||
flex items-center gap-2 px-3 py-2 rounded-lg border-2 transition-all duration-200
|
flex items-center gap-2 px-3 py-2 rounded-l-lg border-2 border-r-0 transition-all duration-200
|
||||||
${isSelected
|
${isSelected
|
||||||
? 'border-primary-blue bg-primary-blue/10'
|
? 'border-primary-blue bg-primary-blue/10'
|
||||||
: 'border-charcoal-outline/40 bg-iron-gray/20 hover:border-charcoal-outline hover:bg-iron-gray/30'
|
: 'border-charcoal-outline/40 bg-iron-gray/20 hover:border-charcoal-outline hover:bg-iron-gray/30'
|
||||||
@@ -388,19 +388,26 @@ export function LeagueDropSection({
|
|||||||
<span className={`text-sm font-medium ${isSelected ? 'text-white' : 'text-gray-400'}`}>
|
<span className={`text-sm font-medium ${isSelected ? 'text-white' : 'text-gray-400'}`}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</span>
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Info button */}
|
{/* Info button - separate from main button */}
|
||||||
<button
|
<button
|
||||||
ref={(el) => { dropRuleRefs.current[option.value] = el; }}
|
ref={(el) => { dropRuleRefs.current[option.value] = el; }}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setActiveDropRuleFlyout(activeDropRuleFlyout === option.value ? null : option.value);
|
setActiveDropRuleFlyout(activeDropRuleFlyout === option.value ? null : option.value);
|
||||||
}}
|
}}
|
||||||
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors shrink-0 ml-1"
|
className={`
|
||||||
>
|
flex h-full items-center justify-center px-2 py-2 rounded-r-lg border-2 border-l-0 transition-all duration-200
|
||||||
<HelpCircle className="w-3 h-3" />
|
${isSelected
|
||||||
</button>
|
? 'border-primary-blue bg-primary-blue/10'
|
||||||
|
: 'border-charcoal-outline/40 bg-iron-gray/20 hover:border-charcoal-outline hover:bg-iron-gray/30'
|
||||||
|
}
|
||||||
|
${disabled ? 'cursor-default opacity-60' : 'cursor-pointer'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<HelpCircle className="w-3.5 h-3.5 text-gray-500 hover:text-primary-blue transition-colors" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Drop Rule Info Flyout */}
|
{/* Drop Rule Info Flyout */}
|
||||||
|
|||||||
@@ -161,8 +161,12 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
|
|||||||
return '🏁';
|
return '🏁';
|
||||||
};
|
};
|
||||||
|
|
||||||
const visibilityIcon = basics.visibility === 'public' ? Eye : EyeOff;
|
// Normalize visibility to new terminology
|
||||||
const visibilityLabel = basics.visibility === 'public' ? 'Public' : 'Private';
|
const isRanked = basics.visibility === 'ranked' || basics.visibility === 'public';
|
||||||
|
const visibilityLabel = isRanked ? 'Ranked' : 'Unranked';
|
||||||
|
const visibilityDescription = isRanked
|
||||||
|
? 'Competitive • Affects ratings'
|
||||||
|
: 'Casual • Friends only';
|
||||||
|
|
||||||
// Calculate total weekend duration
|
// Calculate total weekend duration
|
||||||
const totalWeekendMinutes = (timings.practiceMinutes ?? 0) +
|
const totalWeekendMinutes = (timings.practiceMinutes ?? 0) +
|
||||||
@@ -190,13 +194,15 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
|
|||||||
{basics.description || 'Ready to launch your racing series!'}
|
{basics.description || 'Ready to launch your racing series!'}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${
|
{/* Ranked/Unranked Badge */}
|
||||||
basics.visibility === 'public'
|
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium ${
|
||||||
? 'bg-performance-green/10 text-performance-green'
|
isRanked
|
||||||
: 'bg-warning-amber/10 text-warning-amber'
|
? 'bg-primary-blue/15 text-primary-blue border border-primary-blue/30'
|
||||||
|
: 'bg-neon-aqua/15 text-neon-aqua border border-neon-aqua/30'
|
||||||
}`}>
|
}`}>
|
||||||
{basics.visibility === 'public' ? <Eye className="w-3 h-3" /> : <EyeOff className="w-3 h-3" />}
|
{isRanked ? <Trophy className="w-3 h-3" /> : <Users className="w-3 h-3" />}
|
||||||
{visibilityLabel}
|
<span className="font-semibold">{visibilityLabel}</span>
|
||||||
|
<span className="text-[10px] opacity-70">• {visibilityDescription}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
|
||||||
<Gamepad2 className="w-3 h-3" />
|
<Gamepad2 className="w-3 h-3" />
|
||||||
|
|||||||
@@ -1,9 +1,110 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { User, Users2, Info } from 'lucide-react';
|
import { User, Users2, Info, Check, HelpCircle, X } from 'lucide-react';
|
||||||
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import SegmentedControl from '@/components/ui/SegmentedControl';
|
|
||||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||||
|
import { GameConstraints } from '@gridpilot/racing/domain/value-objects/GameConstraints';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INFO FLYOUT COMPONENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface InfoFlyoutProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutProps) {
|
||||||
|
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const flyoutRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && anchorRef.current && mounted) {
|
||||||
|
const rect = anchorRef.current.getBoundingClientRect();
|
||||||
|
const flyoutWidth = Math.min(320, window.innerWidth - 40);
|
||||||
|
const flyoutHeight = 300;
|
||||||
|
const padding = 16;
|
||||||
|
|
||||||
|
let left = rect.right + 12;
|
||||||
|
let top = rect.top;
|
||||||
|
|
||||||
|
if (left + flyoutWidth > window.innerWidth - padding) {
|
||||||
|
left = rect.left - flyoutWidth - 12;
|
||||||
|
}
|
||||||
|
if (left < padding) {
|
||||||
|
left = Math.max(padding, (window.innerWidth - flyoutWidth) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
top = rect.top - flyoutHeight / 3;
|
||||||
|
if (top + flyoutHeight > window.innerHeight - padding) {
|
||||||
|
top = window.innerHeight - flyoutHeight - padding;
|
||||||
|
}
|
||||||
|
if (top < padding) top = padding;
|
||||||
|
|
||||||
|
left = Math.max(padding, Math.min(left, window.innerWidth - flyoutWidth - padding));
|
||||||
|
|
||||||
|
setPosition({ top, left });
|
||||||
|
}
|
||||||
|
}, [isOpen, anchorRef, mounted]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (flyoutRef.current && !flyoutRef.current.contains(e.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleEscape);
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen || !mounted) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={flyoutRef}
|
||||||
|
className="fixed z-50 w-[320px] max-h-[80vh] overflow-y-auto bg-iron-gray border border-charcoal-outline rounded-xl shadow-2xl animate-fade-in"
|
||||||
|
style={{ top: position.top, left: position.left }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline/50 sticky top-0 bg-iron-gray z-10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HelpCircle className="w-4 h-4 text-primary-blue" />
|
||||||
|
<span className="text-sm font-semibold text-white">{title}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-charcoal-outline transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface LeagueStructureSectionProps {
|
interface LeagueStructureSectionProps {
|
||||||
form: LeagueConfigFormModel;
|
form: LeagueConfigFormModel;
|
||||||
@@ -127,123 +228,368 @@ export function LeagueStructureSection({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Flyout state
|
||||||
|
const [showSoloFlyout, setShowSoloFlyout] = useState(false);
|
||||||
|
const [showTeamsFlyout, setShowTeamsFlyout] = useState(false);
|
||||||
|
const soloInfoRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const teamsInfoRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const isSolo = structure.mode === 'solo';
|
||||||
|
|
||||||
|
// Get game-specific constraints
|
||||||
|
const gameConstraints = useMemo(
|
||||||
|
() => GameConstraints.forGame(form.basics.gameId),
|
||||||
|
[form.basics.gameId]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
{/* League structure selection */}
|
{/* Emotional header */}
|
||||||
<div className="space-y-3">
|
<div className="text-center pb-2">
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-300">
|
<h3 className="text-lg font-semibold text-white mb-2">
|
||||||
<Users2 className="w-4 h-4 text-primary-blue" />
|
How will your drivers compete?
|
||||||
League structure
|
</h3>
|
||||||
</label>
|
<p className="text-sm text-gray-400 max-w-lg mx-auto">
|
||||||
<SegmentedControl
|
Choose your championship format — individual glory or team triumph.
|
||||||
options={[
|
</p>
|
||||||
{
|
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Solo mode capacity */}
|
{/* Mode Selection Cards */}
|
||||||
{structure.mode === 'solo' && (
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-5 space-y-4">
|
{/* Solo Mode Card */}
|
||||||
<div className="flex items-start gap-3">
|
<div className="relative">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10 shrink-0">
|
<button
|
||||||
<User className="w-5 h-5 text-primary-blue" />
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => handleModeChange('solo')}
|
||||||
|
className={`w-full group relative flex flex-col gap-4 p-6 text-left rounded-xl border-2 transition-all duration-200 ${
|
||||||
|
isSolo
|
||||||
|
? 'border-primary-blue bg-gradient-to-br from-primary-blue/15 to-primary-blue/5 shadow-[0_0_30px_rgba(25,140,255,0.25)]'
|
||||||
|
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500 hover:bg-iron-gray/50'
|
||||||
|
} ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${
|
||||||
|
isSolo ? 'bg-primary-blue/30' : 'bg-charcoal-outline/50'
|
||||||
|
}`}>
|
||||||
|
<User className={`w-6 h-6 ${isSolo ? 'text-primary-blue' : 'text-gray-400'}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className={`text-lg font-bold ${isSolo ? 'text-white' : 'text-gray-300'}`}>
|
||||||
|
Solo Drivers
|
||||||
|
</div>
|
||||||
|
<div className={`text-xs ${isSolo ? 'text-primary-blue' : 'text-gray-500'}`}>
|
||||||
|
Individual competition
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Radio indicator */}
|
||||||
|
<div className={`flex h-6 w-6 items-center justify-center rounded-full border-2 shrink-0 transition-colors ${
|
||||||
|
isSolo ? 'border-primary-blue bg-primary-blue' : 'border-gray-500'
|
||||||
|
}`}>
|
||||||
|
{isSolo && <Check className="w-3.5 h-3.5 text-white" />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-sm font-semibold text-white mb-1">Driver capacity</h3>
|
{/* Emotional tagline */}
|
||||||
<p className="text-xs text-gray-500">
|
<p className={`text-sm ${isSolo ? 'text-gray-300' : 'text-gray-500'}`}>
|
||||||
Set the maximum number of drivers who can join your league
|
Every driver for themselves. Pure skill, pure competition, pure glory.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||||
|
<Check className={`w-3.5 h-3.5 ${isSolo ? 'text-performance-green' : 'text-gray-500'}`} />
|
||||||
|
<span>Individual driver standings</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||||
|
<Check className={`w-3.5 h-3.5 ${isSolo ? 'text-performance-green' : 'text-gray-500'}`} />
|
||||||
|
<span>Simple, classic format</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||||
|
<Check className={`w-3.5 h-3.5 ${isSolo ? 'text-performance-green' : 'text-gray-500'}`} />
|
||||||
|
<span>Perfect for any grid size</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Info button */}
|
||||||
|
<button
|
||||||
|
ref={soloInfoRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowSoloFlyout(true)}
|
||||||
|
className="absolute top-2 right-2 flex h-6 w-6 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors"
|
||||||
|
>
|
||||||
|
<HelpCircle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Solo Info Flyout */}
|
||||||
|
<InfoFlyout
|
||||||
|
isOpen={showSoloFlyout}
|
||||||
|
onClose={() => setShowSoloFlyout(false)}
|
||||||
|
title="Solo Drivers Mode"
|
||||||
|
anchorRef={soloInfoRef}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
In solo mode, each driver competes individually. Points are awarded
|
||||||
|
based on finishing position, and standings track individual performance.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Best For</div>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
<li className="flex items-start gap-2 text-xs text-gray-400">
|
||||||
|
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
|
||||||
|
<span>Traditional racing championships</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2 text-xs text-gray-400">
|
||||||
|
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
|
||||||
|
<span>Smaller grids (10-30 drivers)</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2 text-xs text-gray-400">
|
||||||
|
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
|
||||||
|
<span>Quick setup with minimal coordination</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</InfoFlyout>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Teams Mode Card */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => handleModeChange('fixedTeams')}
|
||||||
|
className={`w-full group relative flex flex-col gap-4 p-6 text-left rounded-xl border-2 transition-all duration-200 ${
|
||||||
|
!isSolo
|
||||||
|
? 'border-neon-aqua bg-gradient-to-br from-neon-aqua/15 to-neon-aqua/5 shadow-[0_0_30px_rgba(67,201,230,0.2)]'
|
||||||
|
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500 hover:bg-iron-gray/50'
|
||||||
|
} ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${
|
||||||
|
!isSolo ? 'bg-neon-aqua/30' : 'bg-charcoal-outline/50'
|
||||||
|
}`}>
|
||||||
|
<Users2 className={`w-6 h-6 ${!isSolo ? 'text-neon-aqua' : 'text-gray-400'}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className={`text-lg font-bold ${!isSolo ? 'text-white' : 'text-gray-300'}`}>
|
||||||
|
Team Racing
|
||||||
|
</div>
|
||||||
|
<div className={`text-xs ${!isSolo ? 'text-neon-aqua' : 'text-gray-500'}`}>
|
||||||
|
Shared destiny
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Radio indicator */}
|
||||||
|
<div className={`flex h-6 w-6 items-center justify-center rounded-full border-2 shrink-0 transition-colors ${
|
||||||
|
!isSolo ? 'border-neon-aqua bg-neon-aqua' : 'border-gray-500'
|
||||||
|
}`}>
|
||||||
|
{!isSolo && <Check className="w-3.5 h-3.5 text-deep-graphite" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Emotional tagline */}
|
||||||
|
<p className={`text-sm ${!isSolo ? 'text-gray-300' : 'text-gray-500'}`}>
|
||||||
|
Victory is sweeter together. Build a team, share the podium.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||||
|
<Check className={`w-3.5 h-3.5 ${!isSolo ? 'text-neon-aqua' : 'text-gray-500'}`} />
|
||||||
|
<span>Team & driver standings</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||||
|
<Check className={`w-3.5 h-3.5 ${!isSolo ? 'text-neon-aqua' : 'text-gray-500'}`} />
|
||||||
|
<span>Fixed roster per team</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||||
|
<Check className={`w-3.5 h-3.5 ${!isSolo ? 'text-neon-aqua' : 'text-gray-500'}`} />
|
||||||
|
<span>Great for endurance & pro-am</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Info button */}
|
||||||
|
<button
|
||||||
|
ref={teamsInfoRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowTeamsFlyout(true)}
|
||||||
|
className="absolute top-2 right-2 flex h-6 w-6 items-center justify-center rounded-full text-gray-500 hover:text-neon-aqua hover:bg-neon-aqua/10 transition-colors"
|
||||||
|
>
|
||||||
|
<HelpCircle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Teams Info Flyout */}
|
||||||
|
<InfoFlyout
|
||||||
|
isOpen={showTeamsFlyout}
|
||||||
|
onClose={() => setShowTeamsFlyout(false)}
|
||||||
|
title="Team Racing Mode"
|
||||||
|
anchorRef={teamsInfoRef}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
In team mode, drivers are grouped into fixed teams. Points contribute to
|
||||||
|
both individual and team standings, creating deeper competition.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Best For</div>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
<li className="flex items-start gap-2 text-xs text-gray-400">
|
||||||
|
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
|
||||||
|
<span>Endurance races with driver swaps</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2 text-xs text-gray-400">
|
||||||
|
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
|
||||||
|
<span>Pro-Am style competitions</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2 text-xs text-gray-400">
|
||||||
|
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
|
||||||
|
<span>Larger organized leagues</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</InfoFlyout>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Configuration Panel */}
|
||||||
|
<div className="rounded-xl border border-charcoal-outline bg-gradient-to-b from-iron-gray/50 to-iron-gray/30 p-6 space-y-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${
|
||||||
|
isSolo ? 'bg-primary-blue/20' : 'bg-neon-aqua/20'
|
||||||
|
}`}>
|
||||||
|
{isSolo ? (
|
||||||
|
<User className="w-5 h-5 text-primary-blue" />
|
||||||
|
) : (
|
||||||
|
<Users2 className="w-5 h-5 text-neon-aqua" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-white">
|
||||||
|
{isSolo ? 'Grid size' : 'Team configuration'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{isSolo
|
||||||
|
? 'How many drivers can join your championship?'
|
||||||
|
: 'Configure teams and roster sizes'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Solo mode capacity */}
|
||||||
|
{isSolo && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
<label className="block text-sm font-medium text-gray-300">
|
<label className="block text-sm font-medium text-gray-300">
|
||||||
Max drivers
|
Maximum drivers
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={structure.maxDrivers ?? 24}
|
value={structure.maxDrivers ?? gameConstraints.defaultMaxDrivers}
|
||||||
onChange={(e) => handleMaxDriversChange(e.target.value)}
|
onChange={(e) => handleMaxDriversChange(e.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
min={1}
|
min={gameConstraints.minDrivers}
|
||||||
max={64}
|
max={gameConstraints.maxDrivers}
|
||||||
className="w-32"
|
className="w-40"
|
||||||
/>
|
/>
|
||||||
<div className="space-y-2">
|
<p className="text-xs text-gray-500">
|
||||||
<p className="text-xs text-gray-500 flex items-start gap-1.5">
|
{form.basics.gameId.toUpperCase()} supports up to {gameConstraints.maxDrivers} drivers
|
||||||
<Info className="w-3 h-3 mt-0.5 shrink-0" />
|
</p>
|
||||||
<span>Typical club leagues use 20–30 drivers</span>
|
</div>
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
<div className="space-y-2">
|
||||||
<button
|
<p className="text-xs text-gray-500">Quick select:</p>
|
||||||
type="button"
|
<div className="flex flex-wrap gap-2">
|
||||||
onClick={() => handleMaxDriversChange('20')}
|
<button
|
||||||
disabled={disabled}
|
type="button"
|
||||||
className="px-2 py-1 rounded bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue transition-colors"
|
onClick={() => handleMaxDriversChange('16')}
|
||||||
>
|
disabled={disabled || 16 > gameConstraints.maxDrivers}
|
||||||
Small (20)
|
className={`px-3 py-2 rounded-lg text-xs font-medium transition-all ${
|
||||||
</button>
|
structure.maxDrivers === 16
|
||||||
<button
|
? 'bg-primary-blue text-white'
|
||||||
type="button"
|
: 'bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue disabled:opacity-40 disabled:cursor-not-allowed'
|
||||||
onClick={() => handleMaxDriversChange('24')}
|
}`}
|
||||||
disabled={disabled}
|
>
|
||||||
className="px-2 py-1 rounded bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue transition-colors"
|
Compact (16)
|
||||||
>
|
</button>
|
||||||
Medium (24)
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
onClick={() => handleMaxDriversChange('24')}
|
||||||
|
disabled={disabled || 24 > gameConstraints.maxDrivers}
|
||||||
|
className={`px-3 py-2 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
structure.maxDrivers === 24
|
||||||
|
? 'bg-primary-blue text-white'
|
||||||
|
: 'bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue disabled:opacity-40 disabled:cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Standard (24)
|
||||||
|
</button>
|
||||||
|
{gameConstraints.maxDrivers >= 30 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleMaxDriversChange('30')}
|
onClick={() => handleMaxDriversChange('30')}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="px-2 py-1 rounded bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue transition-colors"
|
className={`px-3 py-2 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
structure.maxDrivers === 30
|
||||||
|
? 'bg-primary-blue text-white'
|
||||||
|
: 'bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Large (30)
|
Full Grid (30)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
|
{gameConstraints.maxDrivers >= 40 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMaxDriversChange('40')}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`px-3 py-2 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
structure.maxDrivers === 40
|
||||||
|
? 'bg-primary-blue text-white'
|
||||||
|
: 'bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Large (40)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{gameConstraints.maxDrivers >= 64 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMaxDriversChange(String(gameConstraints.maxDrivers))}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`px-3 py-2 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
structure.maxDrivers === gameConstraints.maxDrivers
|
||||||
|
? 'bg-primary-blue text-white'
|
||||||
|
: 'bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-primary-blue hover:text-primary-blue'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Max ({gameConstraints.maxDrivers})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Teams mode capacity */}
|
{/* Teams mode capacity */}
|
||||||
{structure.mode === 'fixedTeams' && (
|
{!isSolo && (
|
||||||
<div className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-5 space-y-4">
|
<div className="space-y-5">
|
||||||
<div className="flex items-start gap-3">
|
{/* Quick presets */}
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10 shrink-0">
|
<div className="space-y-3">
|
||||||
<Users2 className="w-5 h-5 text-primary-blue" />
|
<p className="text-xs text-gray-500">Popular configurations:</p>
|
||||||
</div>
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-sm font-semibold text-white mb-1">Team structure</h3>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
Configure the team composition and maximum grid size
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-3">
|
|
||||||
<p className="text-xs text-gray-300">
|
|
||||||
<span className="font-medium text-primary-blue">Quick setup:</span> Choose a common configuration
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2 mt-2">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -251,9 +597,13 @@ export function LeagueStructureSection({
|
|||||||
handleDriversPerTeamChange('2');
|
handleDriversPerTeamChange('2');
|
||||||
}}
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="px-3 py-1.5 rounded bg-iron-gray border border-charcoal-outline text-xs text-gray-300 hover:border-primary-blue hover:text-primary-blue transition-colors"
|
className={`px-3 py-2 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
structure.maxTeams === 10 && structure.driversPerTeam === 2
|
||||||
|
? 'bg-neon-aqua text-deep-graphite'
|
||||||
|
: 'bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-neon-aqua hover:text-neon-aqua'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
10 teams × 2 drivers (20 grid)
|
10 × 2 (20 grid)
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -262,9 +612,13 @@ export function LeagueStructureSection({
|
|||||||
handleDriversPerTeamChange('2');
|
handleDriversPerTeamChange('2');
|
||||||
}}
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="px-3 py-1.5 rounded bg-iron-gray border border-charcoal-outline text-xs text-gray-300 hover:border-primary-blue hover:text-primary-blue transition-colors"
|
className={`px-3 py-2 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
structure.maxTeams === 12 && structure.driversPerTeam === 2
|
||||||
|
? 'bg-neon-aqua text-deep-graphite'
|
||||||
|
: 'bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-neon-aqua hover:text-neon-aqua'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
12 teams × 2 drivers (24 grid)
|
12 × 2 (24 grid)
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -273,17 +627,37 @@ export function LeagueStructureSection({
|
|||||||
handleDriversPerTeamChange('3');
|
handleDriversPerTeamChange('3');
|
||||||
}}
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="px-3 py-1.5 rounded bg-iron-gray border border-charcoal-outline text-xs text-gray-300 hover:border-primary-blue hover:text-primary-blue transition-colors"
|
className={`px-3 py-2 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
structure.maxTeams === 8 && structure.driversPerTeam === 3
|
||||||
|
? 'bg-neon-aqua text-deep-graphite'
|
||||||
|
: 'bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-neon-aqua hover:text-neon-aqua'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
8 teams × 3 drivers (24 grid)
|
8 × 3 (24 grid)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
handleMaxTeamsChange('15');
|
||||||
|
handleDriversPerTeamChange('2');
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`px-3 py-2 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
structure.maxTeams === 15 && structure.driversPerTeam === 2
|
||||||
|
? 'bg-neon-aqua text-deep-graphite'
|
||||||
|
: 'bg-iron-gray border border-charcoal-outline text-gray-300 hover:border-neon-aqua hover:text-neon-aqua'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
15 × 2 (30 grid)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
{/* Manual configuration */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-2 border-t border-charcoal-outline/50">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-300">
|
<label className="block text-sm font-medium text-gray-300">
|
||||||
Max teams
|
Teams
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -292,17 +666,13 @@ export function LeagueStructureSection({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
min={1}
|
min={1}
|
||||||
max={32}
|
max={32}
|
||||||
className="w-32"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 flex items-start gap-1.5">
|
|
||||||
<Info className="w-3 h-3 mt-0.5 shrink-0" />
|
|
||||||
<span>Total competing teams</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-300">
|
<label className="block text-sm font-medium text-gray-300">
|
||||||
Drivers per team
|
Drivers / team
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -311,36 +681,27 @@ export function LeagueStructureSection({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
min={1}
|
min={1}
|
||||||
max={6}
|
max={6}
|
||||||
className="w-32"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 flex items-start gap-1.5">
|
|
||||||
<Info className="w-3 h-3 mt-0.5 shrink-0" />
|
|
||||||
<span>Common: 2–3 drivers</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-300">
|
<label className="block text-sm font-medium text-gray-300">
|
||||||
Total grid size
|
Total grid
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className={`flex items-center justify-center h-10 rounded-lg border ${
|
||||||
<Input
|
!isSolo ? 'bg-neon-aqua/10 border-neon-aqua/30' : 'bg-iron-gray border-charcoal-outline'
|
||||||
type="number"
|
}`}>
|
||||||
value={structure.maxDrivers ?? 0}
|
<span className={`text-lg font-bold ${!isSolo ? 'text-neon-aqua' : 'text-gray-400'}`}>
|
||||||
disabled
|
{structure.maxDrivers ?? 0}
|
||||||
className="w-32"
|
</span>
|
||||||
/>
|
<span className="text-xs text-gray-500 ml-1">drivers</span>
|
||||||
<div className="text-xs text-gray-500">drivers</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 flex items-start gap-1.5">
|
|
||||||
<Info className="w-3 h-3 mt-0.5 shrink-0" />
|
|
||||||
<span>Auto-calculated from teams × drivers</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
460
apps/website/components/leagues/LeagueVisibilitySection.tsx
Normal file
460
apps/website/components/leagues/LeagueVisibilitySection.tsx
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Trophy, Users, Check, HelpCircle, X } from 'lucide-react';
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||||
|
|
||||||
|
// Minimum drivers for ranked leagues
|
||||||
|
const MIN_RANKED_DRIVERS = 10;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INFO FLYOUT COMPONENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface InfoFlyoutProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutProps) {
|
||||||
|
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const flyoutRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && anchorRef.current && mounted) {
|
||||||
|
const rect = anchorRef.current.getBoundingClientRect();
|
||||||
|
const flyoutWidth = Math.min(340, window.innerWidth - 40);
|
||||||
|
const flyoutHeight = 350;
|
||||||
|
const padding = 16;
|
||||||
|
|
||||||
|
let left = rect.right + 12;
|
||||||
|
let top = rect.top;
|
||||||
|
|
||||||
|
if (left + flyoutWidth > window.innerWidth - padding) {
|
||||||
|
left = rect.left - flyoutWidth - 12;
|
||||||
|
}
|
||||||
|
if (left < padding) {
|
||||||
|
left = Math.max(padding, (window.innerWidth - flyoutWidth) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
top = rect.top - flyoutHeight / 3;
|
||||||
|
if (top + flyoutHeight > window.innerHeight - padding) {
|
||||||
|
top = window.innerHeight - flyoutHeight - padding;
|
||||||
|
}
|
||||||
|
if (top < padding) top = padding;
|
||||||
|
|
||||||
|
left = Math.max(padding, Math.min(left, window.innerWidth - flyoutWidth - padding));
|
||||||
|
|
||||||
|
setPosition({ top, left });
|
||||||
|
}
|
||||||
|
}, [isOpen, anchorRef, mounted]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (flyoutRef.current && !flyoutRef.current.contains(e.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleEscape);
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen || !mounted) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={flyoutRef}
|
||||||
|
className="fixed z-50 w-[340px] max-h-[80vh] overflow-y-auto bg-iron-gray border border-charcoal-outline rounded-xl shadow-2xl animate-fade-in"
|
||||||
|
style={{ top: position.top, left: position.left }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline/50 sticky top-0 bg-iron-gray z-10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HelpCircle className="w-4 h-4 text-primary-blue" />
|
||||||
|
<span className="text-sm font-semibold text-white">{title}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-charcoal-outline transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeagueVisibilitySectionProps {
|
||||||
|
form: LeagueConfigFormModel;
|
||||||
|
onChange?: (form: LeagueConfigFormModel) => void;
|
||||||
|
errors?: {
|
||||||
|
visibility?: string;
|
||||||
|
};
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeagueVisibilitySection({
|
||||||
|
form,
|
||||||
|
onChange,
|
||||||
|
errors,
|
||||||
|
readOnly,
|
||||||
|
}: LeagueVisibilitySectionProps) {
|
||||||
|
const basics = form.basics;
|
||||||
|
const disabled = readOnly || !onChange;
|
||||||
|
|
||||||
|
// Flyout state
|
||||||
|
const [showRankedFlyout, setShowRankedFlyout] = useState(false);
|
||||||
|
const [showUnrankedFlyout, setShowUnrankedFlyout] = useState(false);
|
||||||
|
const rankedInfoRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const unrankedInfoRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
// Normalize visibility to new terminology
|
||||||
|
const isRanked = basics.visibility === 'ranked' || basics.visibility === 'public';
|
||||||
|
|
||||||
|
// Auto-update minDrivers when switching to ranked
|
||||||
|
const handleVisibilityChange = (visibility: 'ranked' | 'unranked') => {
|
||||||
|
if (!onChange) return;
|
||||||
|
|
||||||
|
// If switching to ranked and current maxDrivers is below minimum, update it
|
||||||
|
if (visibility === 'ranked' && form.structure.maxDrivers < MIN_RANKED_DRIVERS) {
|
||||||
|
onChange({
|
||||||
|
...form,
|
||||||
|
basics: { ...form.basics, visibility },
|
||||||
|
structure: { ...form.structure, maxDrivers: MIN_RANKED_DRIVERS },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onChange({
|
||||||
|
...form,
|
||||||
|
basics: { ...form.basics, visibility },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Emotional header for the step */}
|
||||||
|
<div className="text-center pb-2">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">
|
||||||
|
Choose your league's destiny
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-400 max-w-lg mx-auto">
|
||||||
|
Will you compete for glory on the global leaderboards, or race with friends in a private series?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* League Type Selection */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Ranked (Public) Option */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => handleVisibilityChange('ranked')}
|
||||||
|
className={`w-full group relative flex flex-col gap-4 p-6 text-left rounded-xl border-2 transition-all duration-200 ${
|
||||||
|
isRanked
|
||||||
|
? 'border-primary-blue bg-gradient-to-br from-primary-blue/15 to-primary-blue/5 shadow-[0_0_30px_rgba(25,140,255,0.25)]'
|
||||||
|
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500 hover:bg-iron-gray/50'
|
||||||
|
} ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`flex h-14 w-14 items-center justify-center rounded-xl ${
|
||||||
|
isRanked ? 'bg-primary-blue/30' : 'bg-charcoal-outline/50'
|
||||||
|
}`}>
|
||||||
|
<Trophy className={`w-7 h-7 ${isRanked ? 'text-primary-blue' : 'text-gray-400'}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className={`text-xl font-bold ${isRanked ? 'text-white' : 'text-gray-300'}`}>
|
||||||
|
Ranked
|
||||||
|
</div>
|
||||||
|
<div className={`text-sm ${isRanked ? 'text-primary-blue' : 'text-gray-500'}`}>
|
||||||
|
Compete for glory
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Radio indicator */}
|
||||||
|
<div className={`flex h-7 w-7 items-center justify-center rounded-full border-2 shrink-0 transition-colors ${
|
||||||
|
isRanked ? 'border-primary-blue bg-primary-blue' : 'border-gray-500'
|
||||||
|
}`}>
|
||||||
|
{isRanked && <Check className="w-4 h-4 text-white" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Emotional tagline */}
|
||||||
|
<p className={`text-sm ${isRanked ? 'text-gray-300' : 'text-gray-500'}`}>
|
||||||
|
Your results matter. Build your reputation in the global standings and climb the ranks.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<div className="space-y-2.5 py-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<Check className="w-4 h-4 text-performance-green" />
|
||||||
|
<span>Discoverable by all drivers</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<Check className="w-4 h-4 text-performance-green" />
|
||||||
|
<span>Affects driver ratings & rankings</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<Check className="w-4 h-4 text-performance-green" />
|
||||||
|
<span>Featured on leaderboards</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Requirement badge */}
|
||||||
|
<div className="flex items-center gap-2 mt-auto px-3 py-2 rounded-lg bg-warning-amber/10 border border-warning-amber/20 w-fit">
|
||||||
|
<Users className="w-4 h-4 text-warning-amber" />
|
||||||
|
<span className="text-xs text-warning-amber font-medium">
|
||||||
|
Requires {MIN_RANKED_DRIVERS}+ drivers for competitive integrity
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Info button */}
|
||||||
|
<button
|
||||||
|
ref={rankedInfoRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowRankedFlyout(true)}
|
||||||
|
className="absolute top-3 right-3 flex h-7 w-7 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors"
|
||||||
|
>
|
||||||
|
<HelpCircle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ranked Info Flyout */}
|
||||||
|
<InfoFlyout
|
||||||
|
isOpen={showRankedFlyout}
|
||||||
|
onClose={() => setShowRankedFlyout(false)}
|
||||||
|
title="Ranked Leagues"
|
||||||
|
anchorRef={rankedInfoRef}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Ranked leagues are competitive series where results matter. Your performance
|
||||||
|
affects your driver rating and contributes to global leaderboards.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Requirements</div>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
<li className="flex items-start gap-2 text-xs text-gray-400">
|
||||||
|
<Users className="w-3.5 h-3.5 text-warning-amber shrink-0 mt-0.5" />
|
||||||
|
<span><strong className="text-white">Minimum {MIN_RANKED_DRIVERS} drivers</strong> for competitive integrity</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2 text-xs text-gray-400">
|
||||||
|
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
|
||||||
|
<span>Anyone can discover and join your league</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Benefits</div>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
<li className="flex items-start gap-2 text-xs text-gray-400">
|
||||||
|
<Trophy className="w-3.5 h-3.5 text-primary-blue shrink-0 mt-0.5" />
|
||||||
|
<span>Results affect driver ratings and rankings</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2 text-xs text-gray-400">
|
||||||
|
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
|
||||||
|
<span>Featured in league discovery</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</InfoFlyout>
|
||||||
|
|
||||||
|
{/* Unranked (Private) Option */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => handleVisibilityChange('unranked')}
|
||||||
|
className={`w-full group relative flex flex-col gap-4 p-6 text-left rounded-xl border-2 transition-all duration-200 ${
|
||||||
|
!isRanked
|
||||||
|
? 'border-neon-aqua bg-gradient-to-br from-neon-aqua/15 to-neon-aqua/5 shadow-[0_0_30px_rgba(67,201,230,0.2)]'
|
||||||
|
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500 hover:bg-iron-gray/50'
|
||||||
|
} ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`flex h-14 w-14 items-center justify-center rounded-xl ${
|
||||||
|
!isRanked ? 'bg-neon-aqua/30' : 'bg-charcoal-outline/50'
|
||||||
|
}`}>
|
||||||
|
<Users className={`w-7 h-7 ${!isRanked ? 'text-neon-aqua' : 'text-gray-400'}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className={`text-xl font-bold ${!isRanked ? 'text-white' : 'text-gray-300'}`}>
|
||||||
|
Unranked
|
||||||
|
</div>
|
||||||
|
<div className={`text-sm ${!isRanked ? 'text-neon-aqua' : 'text-gray-500'}`}>
|
||||||
|
Race with friends
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Radio indicator */}
|
||||||
|
<div className={`flex h-7 w-7 items-center justify-center rounded-full border-2 shrink-0 transition-colors ${
|
||||||
|
!isRanked ? 'border-neon-aqua bg-neon-aqua' : 'border-gray-500'
|
||||||
|
}`}>
|
||||||
|
{!isRanked && <Check className="w-4 h-4 text-deep-graphite" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Emotional tagline */}
|
||||||
|
<p className={`text-sm ${!isRanked ? 'text-gray-300' : 'text-gray-500'}`}>
|
||||||
|
Pure racing fun. No pressure, no rankings — just you and your crew hitting the track.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<div className="space-y-2.5 py-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<Check className={`w-4 h-4 ${!isRanked ? 'text-neon-aqua' : 'text-gray-500'}`} />
|
||||||
|
<span>Private, invite-only access</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<Check className={`w-4 h-4 ${!isRanked ? 'text-neon-aqua' : 'text-gray-500'}`} />
|
||||||
|
<span>Zero impact on your rating</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<Check className={`w-4 h-4 ${!isRanked ? 'text-neon-aqua' : 'text-gray-500'}`} />
|
||||||
|
<span>Perfect for practice & fun</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flexibility badge */}
|
||||||
|
<div className="flex items-center gap-2 mt-auto px-3 py-2 rounded-lg bg-neon-aqua/10 border border-neon-aqua/20 w-fit">
|
||||||
|
<Users className={`w-4 h-4 ${!isRanked ? 'text-neon-aqua' : 'text-gray-400'}`} />
|
||||||
|
<span className={`text-xs font-medium ${!isRanked ? 'text-neon-aqua' : 'text-gray-400'}`}>
|
||||||
|
Any size — even 2 friends
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Info button */}
|
||||||
|
<button
|
||||||
|
ref={unrankedInfoRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowUnrankedFlyout(true)}
|
||||||
|
className="absolute top-3 right-3 flex h-7 w-7 items-center justify-center rounded-full text-gray-500 hover:text-neon-aqua hover:bg-neon-aqua/10 transition-colors"
|
||||||
|
>
|
||||||
|
<HelpCircle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unranked Info Flyout */}
|
||||||
|
<InfoFlyout
|
||||||
|
isOpen={showUnrankedFlyout}
|
||||||
|
onClose={() => setShowUnrankedFlyout(false)}
|
||||||
|
title="Unranked Leagues"
|
||||||
|
anchorRef={unrankedInfoRef}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Unranked leagues are casual, private series for racing with friends.
|
||||||
|
Results don't affect driver ratings, so you can practice and have fun
|
||||||
|
without pressure.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Perfect For</div>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
<li className="flex items-start gap-2 text-xs text-gray-400">
|
||||||
|
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
|
||||||
|
<span>Private racing with friends</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2 text-xs text-gray-400">
|
||||||
|
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
|
||||||
|
<span>Practice and training sessions</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2 text-xs text-gray-400">
|
||||||
|
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
|
||||||
|
<span>Small groups (2+ drivers)</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Features</div>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
<li className="flex items-start gap-2 text-xs text-gray-400">
|
||||||
|
<Users className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
|
||||||
|
<span>Invite-only membership</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2 text-xs text-gray-400">
|
||||||
|
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
|
||||||
|
<span>Full stats and standings (internal only)</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</InfoFlyout>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errors?.visibility && (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-warning-amber/10 border border-warning-amber/20">
|
||||||
|
<HelpCircle className="w-4 h-4 text-warning-amber shrink-0" />
|
||||||
|
<p className="text-xs text-warning-amber">{errors.visibility}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contextual info based on selection */}
|
||||||
|
<div className={`rounded-xl p-5 border transition-all duration-300 ${
|
||||||
|
isRanked
|
||||||
|
? 'bg-primary-blue/5 border-primary-blue/20'
|
||||||
|
: 'bg-neon-aqua/5 border-neon-aqua/20'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{isRanked ? (
|
||||||
|
<>
|
||||||
|
<Trophy className="w-5 h-5 text-primary-blue shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white mb-1">Ready to compete</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Your league will be visible to all GridPilot drivers. Results will affect driver ratings
|
||||||
|
and contribute to the global leaderboards. Make sure you have at least {MIN_RANKED_DRIVERS} drivers
|
||||||
|
to ensure competitive integrity.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Users className="w-5 h-5 text-neon-aqua shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white mb-1">Private racing awaits</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Your league will be invite-only. Perfect for racing with friends, practice sessions,
|
||||||
|
or any time you want to have fun without affecting your official ratings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,126 +1,185 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Card from '../ui/Card';
|
import {
|
||||||
|
Users,
|
||||||
|
Trophy,
|
||||||
|
Award,
|
||||||
|
Crown,
|
||||||
|
Star,
|
||||||
|
TrendingUp,
|
||||||
|
Shield,
|
||||||
|
ChevronRight,
|
||||||
|
UserPlus,
|
||||||
|
Zap,
|
||||||
|
Clock,
|
||||||
|
} from 'lucide-react';
|
||||||
import { getImageService } from '@/lib/di-container';
|
import { getImageService } from '@/lib/di-container';
|
||||||
|
|
||||||
interface TeamCardProps {
|
interface TeamCardProps {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
description?: string;
|
||||||
logo?: string;
|
logo?: string;
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
leagues: string[];
|
|
||||||
rating?: number | null;
|
rating?: number | null;
|
||||||
totalWins?: number;
|
totalWins?: number;
|
||||||
totalRaces?: number;
|
totalRaces?: number;
|
||||||
performanceLevel?: 'beginner' | 'intermediate' | 'advanced' | 'pro';
|
performanceLevel?: 'beginner' | 'intermediate' | 'advanced' | 'pro';
|
||||||
|
isRecruiting?: boolean;
|
||||||
|
specialization?: 'endurance' | 'sprint' | 'mixed';
|
||||||
|
leagues?: string[];
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPerformanceBadge(level?: string) {
|
||||||
|
switch (level) {
|
||||||
|
case 'pro':
|
||||||
|
return { icon: Crown, label: 'Pro', color: 'text-yellow-400', bgColor: 'bg-yellow-500/20 border-yellow-500/30' };
|
||||||
|
case 'advanced':
|
||||||
|
return { icon: Star, label: 'Advanced', color: 'text-purple-400', bgColor: 'bg-purple-500/20 border-purple-500/30' };
|
||||||
|
case 'intermediate':
|
||||||
|
return { icon: TrendingUp, label: 'Intermediate', color: 'text-primary-blue', bgColor: 'bg-primary-blue/20 border-primary-blue/30' };
|
||||||
|
case 'beginner':
|
||||||
|
return { icon: Shield, label: 'Beginner', color: 'text-green-400', bgColor: 'bg-green-500/20 border-green-500/30' };
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSpecializationBadge(specialization?: string) {
|
||||||
|
switch (specialization) {
|
||||||
|
case 'endurance':
|
||||||
|
return { icon: Clock, label: 'Endurance', color: 'text-orange-400' };
|
||||||
|
case 'sprint':
|
||||||
|
return { icon: Zap, label: 'Sprint', color: 'text-neon-aqua' };
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function TeamCard({
|
export default function TeamCard({
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
description,
|
||||||
logo,
|
logo,
|
||||||
memberCount,
|
memberCount,
|
||||||
leagues,
|
|
||||||
rating,
|
rating,
|
||||||
totalWins,
|
totalWins,
|
||||||
totalRaces,
|
totalRaces,
|
||||||
performanceLevel,
|
performanceLevel,
|
||||||
|
isRecruiting,
|
||||||
|
specialization,
|
||||||
onClick,
|
onClick,
|
||||||
}: TeamCardProps) {
|
}: TeamCardProps) {
|
||||||
const performanceBadgeColors = {
|
const imageService = getImageService();
|
||||||
beginner: 'bg-green-500/20 text-green-400',
|
const logoUrl = logo || imageService.getTeamLogo(id);
|
||||||
intermediate: 'bg-blue-500/20 text-blue-400',
|
const performanceBadge = getPerformanceBadge(performanceLevel);
|
||||||
advanced: 'bg-purple-500/20 text-purple-400',
|
const specializationBadge = getSpecializationBadge(specialization);
|
||||||
pro: 'bg-red-500/20 text-red-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="cursor-pointer hover:scale-[1.03] transition-transform duration-150"
|
className="group relative cursor-pointer h-full"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<Card>
|
{/* Card Container */}
|
||||||
<div className="space-y-4">
|
<div className="relative h-full rounded-xl bg-iron-gray border border-charcoal-outline overflow-hidden transition-all duration-200 hover:border-purple-500/50 hover:shadow-[0_0_30px_rgba(168,85,247,0.15)] hover:bg-iron-gray/80 flex flex-col">
|
||||||
|
{/* Header with Logo */}
|
||||||
|
<div className="relative p-4 pb-0">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="w-16 h-16 bg-charcoal-outline rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">
|
{/* Logo */}
|
||||||
|
<div className="w-14 h-14 rounded-xl bg-charcoal-outline flex items-center justify-center flex-shrink-0 overflow-hidden border border-charcoal-outline">
|
||||||
<Image
|
<Image
|
||||||
src={logo || getImageService().getTeamLogo(id)}
|
src={logoUrl}
|
||||||
alt={name}
|
alt={name}
|
||||||
width={64}
|
width={56}
|
||||||
height={64}
|
height={56}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Title & Badges */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-lg font-semibold text-white truncate">
|
<div className="flex items-start justify-between gap-2">
|
||||||
{name}
|
<h3 className="text-base font-semibold text-white truncate group-hover:text-purple-400 transition-colors">
|
||||||
</h3>
|
{name}
|
||||||
<p className="text-sm text-gray-400">
|
</h3>
|
||||||
{memberCount} {memberCount === 1 ? 'member' : 'members'}
|
{isRecruiting && (
|
||||||
</p>
|
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-performance-green/20 text-performance-green border border-performance-green/30 whitespace-nowrap">
|
||||||
{typeof rating === 'number' && (
|
<UserPlus className="w-3 h-3" />
|
||||||
<p className="text-xs text-primary-blue mt-1">
|
Recruiting
|
||||||
Team rating: <span className="font-semibold">{Math.round(rating)}</span>
|
</span>
|
||||||
</p>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Performance Level */}
|
||||||
|
{performanceBadge && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<span className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border ${performanceBadge.bgColor}`}>
|
||||||
|
<performanceBadge.icon className={`w-3 h-3 ${performanceBadge.color}`} />
|
||||||
|
<span className={performanceBadge.color}>{performanceBadge.label}</span>
|
||||||
|
</span>
|
||||||
|
{specializationBadge && (
|
||||||
|
<span className="flex items-center gap-1 text-[10px] text-gray-500">
|
||||||
|
<specializationBadge.icon className={`w-3 h-3 ${specializationBadge.color}`} />
|
||||||
|
{specializationBadge.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{performanceLevel && (
|
{/* Content */}
|
||||||
<div>
|
<div className="p-4 flex flex-col flex-1">
|
||||||
<span
|
{/* Description */}
|
||||||
className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${
|
<p className="text-xs text-gray-500 line-clamp-2 mb-4 h-8">
|
||||||
performanceBadgeColors[performanceLevel]
|
{description || 'No description available'}
|
||||||
}`}
|
</p>
|
||||||
>
|
|
||||||
{performanceLevel.charAt(0).toUpperCase() + performanceLevel.slice(1)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4 text-center">
|
{/* Stats Grid */}
|
||||||
<div>
|
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||||
<div className="text-sm text-gray-400">Rating</div>
|
<div className="text-center p-2 rounded-lg bg-charcoal-outline/30">
|
||||||
<div className="text-lg font-semibold text-primary-blue">
|
<div className="text-[10px] text-gray-500 mb-0.5">Rating</div>
|
||||||
{typeof rating === 'number' ? Math.round(rating) : '—'}
|
<div className="text-sm font-semibold text-primary-blue">
|
||||||
|
{typeof rating === 'number' ? Math.round(rating).toLocaleString() : '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="text-center p-2 rounded-lg bg-charcoal-outline/30">
|
||||||
<div className="text-sm text-gray-400">Wins</div>
|
<div className="text-[10px] text-gray-500 mb-0.5">Wins</div>
|
||||||
<div className="text-lg font-semibold text-green-400">
|
<div className="text-sm font-semibold text-performance-green">
|
||||||
{totalWins ?? 0}
|
{totalWins ?? 0}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="text-center p-2 rounded-lg bg-charcoal-outline/30">
|
||||||
<div className="text-sm text-gray-400">Races</div>
|
<div className="text-[10px] text-gray-500 mb-0.5">Races</div>
|
||||||
<div className="text-lg font-semibold text-white">
|
<div className="text-sm font-semibold text-white">
|
||||||
{totalRaces ?? 0}
|
{totalRaces ?? 0}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* Spacer */}
|
||||||
<p className="text-sm font-medium text-gray-400">Active in:</p>
|
<div className="flex-1" />
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{leagues.slice(0, 3).map((league, idx) => (
|
{/* Footer */}
|
||||||
<span
|
<div className="flex items-center justify-between pt-3 border-t border-charcoal-outline/50 mt-auto">
|
||||||
key={idx}
|
<div className="flex items-center gap-2 text-[10px] text-gray-500">
|
||||||
className="inline-block px-2 py-1 bg-charcoal-outline text-gray-300 rounded text-xs"
|
<Users className="w-3 h-3" />
|
||||||
>
|
<span>
|
||||||
{league}
|
{memberCount} {memberCount === 1 ? 'member' : 'members'}
|
||||||
</span>
|
</span>
|
||||||
))}
|
</div>
|
||||||
{leagues.length > 3 && (
|
|
||||||
<span className="inline-block px-2 py-1 bg-charcoal-outline text-gray-400 rounded text-xs">
|
{/* View Arrow */}
|
||||||
+{leagues.length - 3} more
|
<div className="flex items-center gap-1 text-[10px] text-gray-500 group-hover:text-purple-400 transition-colors">
|
||||||
</span>
|
<span>View</span>
|
||||||
)}
|
<ChevronRight className="w-3 h-3 transition-transform group-hover:translate-x-0.5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9,12 +9,16 @@ import {
|
|||||||
getDriverRepository,
|
getDriverRepository,
|
||||||
getCreateLeagueWithSeasonAndScoringUseCase,
|
getCreateLeagueWithSeasonAndScoringUseCase,
|
||||||
} from '@/lib/di-container';
|
} from '@/lib/di-container';
|
||||||
|
import { LeagueName } from '@gridpilot/racing/domain/value-objects/LeagueName';
|
||||||
|
import { LeagueDescription } from '@gridpilot/racing/domain/value-objects/LeagueDescription';
|
||||||
|
import { GameConstraints } from '@gridpilot/racing/domain/value-objects/GameConstraints';
|
||||||
|
|
||||||
export type WizardStep = 1 | 2 | 3 | 4 | 5;
|
export type WizardStep = 1 | 2 | 3 | 4 | 5 | 6;
|
||||||
|
|
||||||
export interface WizardErrors {
|
export interface WizardErrors {
|
||||||
basics?: {
|
basics?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
description?: string;
|
||||||
visibility?: string;
|
visibility?: string;
|
||||||
};
|
};
|
||||||
structure?: {
|
structure?: {
|
||||||
@@ -43,42 +47,86 @@ export function validateLeagueWizardStep(
|
|||||||
): WizardErrors {
|
): WizardErrors {
|
||||||
const errors: WizardErrors = {};
|
const errors: WizardErrors = {};
|
||||||
|
|
||||||
|
// Step 1: Basics (name, description, game)
|
||||||
if (step === 1) {
|
if (step === 1) {
|
||||||
const basicsErrors: NonNullable<WizardErrors['basics']> = {};
|
const basicsErrors: NonNullable<WizardErrors['basics']> = {};
|
||||||
if (!form.basics.name.trim()) {
|
|
||||||
basicsErrors.name = 'Name is required';
|
// Use LeagueName value object for validation
|
||||||
|
const nameValidation = LeagueName.validate(form.basics.name);
|
||||||
|
if (!nameValidation.valid) {
|
||||||
|
basicsErrors.name = nameValidation.error;
|
||||||
}
|
}
|
||||||
if (!form.basics.visibility) {
|
|
||||||
basicsErrors.visibility = 'Visibility is required';
|
// Use LeagueDescription value object for validation
|
||||||
|
const descValidation = LeagueDescription.validate(form.basics.description ?? '');
|
||||||
|
if (!descValidation.valid) {
|
||||||
|
basicsErrors.description = descValidation.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(basicsErrors).length > 0) {
|
if (Object.keys(basicsErrors).length > 0) {
|
||||||
errors.basics = basicsErrors;
|
errors.basics = basicsErrors;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 2: Visibility (ranked/unranked)
|
||||||
if (step === 2) {
|
if (step === 2) {
|
||||||
|
const basicsErrors: NonNullable<WizardErrors['basics']> = {};
|
||||||
|
|
||||||
|
if (!form.basics.visibility) {
|
||||||
|
basicsErrors.visibility = 'Visibility is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(basicsErrors).length > 0) {
|
||||||
|
errors.basics = basicsErrors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Structure (solo vs teams)
|
||||||
|
if (step === 3) {
|
||||||
const structureErrors: NonNullable<WizardErrors['structure']> = {};
|
const structureErrors: NonNullable<WizardErrors['structure']> = {};
|
||||||
|
const gameConstraints = GameConstraints.forGame(form.basics.gameId);
|
||||||
|
|
||||||
if (form.structure.mode === 'solo') {
|
if (form.structure.mode === 'solo') {
|
||||||
if (!form.structure.maxDrivers || form.structure.maxDrivers <= 0) {
|
if (!form.structure.maxDrivers || form.structure.maxDrivers <= 0) {
|
||||||
structureErrors.maxDrivers =
|
structureErrors.maxDrivers =
|
||||||
'Max drivers must be greater than 0 for solo leagues';
|
'Max drivers must be greater than 0 for solo leagues';
|
||||||
|
} else {
|
||||||
|
// Validate against game constraints
|
||||||
|
const driverValidation = gameConstraints.validateDriverCount(form.structure.maxDrivers);
|
||||||
|
if (!driverValidation.valid) {
|
||||||
|
structureErrors.maxDrivers = driverValidation.error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (form.structure.mode === 'fixedTeams') {
|
} else if (form.structure.mode === 'fixedTeams') {
|
||||||
if (!form.structure.maxTeams || form.structure.maxTeams <= 0) {
|
if (!form.structure.maxTeams || form.structure.maxTeams <= 0) {
|
||||||
structureErrors.maxTeams =
|
structureErrors.maxTeams =
|
||||||
'Max teams must be greater than 0 for team leagues';
|
'Max teams must be greater than 0 for team leagues';
|
||||||
|
} else {
|
||||||
|
// Validate against game constraints
|
||||||
|
const teamValidation = gameConstraints.validateTeamCount(form.structure.maxTeams);
|
||||||
|
if (!teamValidation.valid) {
|
||||||
|
structureErrors.maxTeams = teamValidation.error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!form.structure.driversPerTeam || form.structure.driversPerTeam <= 0) {
|
if (!form.structure.driversPerTeam || form.structure.driversPerTeam <= 0) {
|
||||||
structureErrors.driversPerTeam =
|
structureErrors.driversPerTeam =
|
||||||
'Drivers per team must be greater than 0';
|
'Drivers per team must be greater than 0';
|
||||||
}
|
}
|
||||||
|
// Validate total driver count
|
||||||
|
if (form.structure.maxDrivers) {
|
||||||
|
const driverValidation = gameConstraints.validateDriverCount(form.structure.maxDrivers);
|
||||||
|
if (!driverValidation.valid) {
|
||||||
|
structureErrors.maxDrivers = driverValidation.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (Object.keys(structureErrors).length > 0) {
|
if (Object.keys(structureErrors).length > 0) {
|
||||||
errors.structure = structureErrors;
|
errors.structure = structureErrors;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step === 3) {
|
// Step 4: Schedule (timings)
|
||||||
|
if (step === 4) {
|
||||||
const timingsErrors: NonNullable<WizardErrors['timings']> = {};
|
const timingsErrors: NonNullable<WizardErrors['timings']> = {};
|
||||||
if (!form.timings.qualifyingMinutes || form.timings.qualifyingMinutes <= 0) {
|
if (!form.timings.qualifyingMinutes || form.timings.qualifyingMinutes <= 0) {
|
||||||
timingsErrors.qualifyingMinutes =
|
timingsErrors.qualifyingMinutes =
|
||||||
@@ -93,7 +141,8 @@ export function validateLeagueWizardStep(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step === 4) {
|
// Step 5: Scoring
|
||||||
|
if (step === 5) {
|
||||||
const scoringErrors: NonNullable<WizardErrors['scoring']> = {};
|
const scoringErrors: NonNullable<WizardErrors['scoring']> = {};
|
||||||
if (!form.scoring.patternId && !form.scoring.customScoringEnabled) {
|
if (!form.scoring.patternId && !form.scoring.customScoringEnabled) {
|
||||||
scoringErrors.patternId =
|
scoringErrors.patternId =
|
||||||
@@ -104,6 +153,8 @@ export function validateLeagueWizardStep(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 6: Review - no validation needed, it's just review
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +188,7 @@ export function validateAllLeagueWizardSteps(
|
|||||||
merge(validateLeagueWizardStep(form, 2));
|
merge(validateLeagueWizardStep(form, 2));
|
||||||
merge(validateLeagueWizardStep(form, 3));
|
merge(validateLeagueWizardStep(form, 3));
|
||||||
merge(validateLeagueWizardStep(form, 4));
|
merge(validateLeagueWizardStep(form, 4));
|
||||||
|
merge(validateLeagueWizardStep(form, 5));
|
||||||
|
|
||||||
return aggregate;
|
return aggregate;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
|
import type { LeagueVisibilityType } from '../../domain/value-objects/LeagueVisibility';
|
||||||
|
|
||||||
export type LeagueStructureMode = 'solo' | 'fixedTeams';
|
export type LeagueStructureMode = 'solo' | 'fixedTeams';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* League visibility determines public visibility and ranking status.
|
||||||
|
* - 'ranked': Public, competitive, affects driver ratings. Requires min 10 drivers.
|
||||||
|
* - 'unranked': Private, casual with friends. No rating impact. Any number of drivers.
|
||||||
|
*
|
||||||
|
* For backward compatibility, 'public'/'private' are also supported in the form,
|
||||||
|
* but the domain uses 'ranked'/'unranked'.
|
||||||
|
*/
|
||||||
|
export type LeagueVisibilityFormValue = LeagueVisibilityType | 'public' | 'private';
|
||||||
|
|
||||||
export interface LeagueStructureFormDTO {
|
export interface LeagueStructureFormDTO {
|
||||||
mode: LeagueStructureMode;
|
mode: LeagueStructureMode;
|
||||||
maxDrivers: number;
|
maxDrivers: number;
|
||||||
@@ -50,8 +62,18 @@ export interface LeagueConfigFormModel {
|
|||||||
basics: {
|
basics: {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
visibility: 'public' | 'private';
|
/**
|
||||||
|
* League visibility/ranking mode.
|
||||||
|
* - 'ranked' (or legacy 'public'): Competitive, public, affects ratings. Min 10 drivers.
|
||||||
|
* - 'unranked' (or legacy 'private'): Casual with friends, no rating impact.
|
||||||
|
*/
|
||||||
|
visibility: LeagueVisibilityFormValue;
|
||||||
gameId: string;
|
gameId: string;
|
||||||
|
/**
|
||||||
|
* League logo as base64 data URL (optional).
|
||||||
|
* Format: data:image/png;base64,... or data:image/jpeg;base64,...
|
||||||
|
*/
|
||||||
|
logoDataUrl?: string;
|
||||||
};
|
};
|
||||||
structure: LeagueStructureFormDTO;
|
structure: LeagueStructureFormDTO;
|
||||||
championships: LeagueChampionshipsFormDTO;
|
championships: LeagueChampionshipsFormDTO;
|
||||||
@@ -59,3 +81,21 @@ export interface LeagueConfigFormModel {
|
|||||||
dropPolicy: LeagueDropPolicyFormDTO;
|
dropPolicy: LeagueDropPolicyFormDTO;
|
||||||
timings: LeagueTimingsFormDTO;
|
timings: LeagueTimingsFormDTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to normalize visibility values to new terminology.
|
||||||
|
* Maps 'public' -> 'ranked' and 'private' -> 'unranked'.
|
||||||
|
*/
|
||||||
|
export function normalizeVisibility(value: LeagueVisibilityFormValue): LeagueVisibilityType {
|
||||||
|
if (value === 'public' || value === 'ranked') return 'ranked';
|
||||||
|
return 'unranked';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to convert new terminology to legacy for backward compatibility.
|
||||||
|
* Maps 'ranked' -> 'public' and 'unranked' -> 'private'.
|
||||||
|
*/
|
||||||
|
export function toLegacyVisibility(value: LeagueVisibilityFormValue): 'public' | 'private' {
|
||||||
|
if (value === 'ranked' || value === 'public') return 'public';
|
||||||
|
return 'private';
|
||||||
|
}
|
||||||
@@ -8,11 +8,27 @@ import type {
|
|||||||
LeagueScoringPresetProvider,
|
LeagueScoringPresetProvider,
|
||||||
LeagueScoringPresetDTO,
|
LeagueScoringPresetDTO,
|
||||||
} from '../ports/LeagueScoringPresetProvider';
|
} from '../ports/LeagueScoringPresetProvider';
|
||||||
|
import {
|
||||||
|
LeagueVisibility,
|
||||||
|
MIN_RANKED_LEAGUE_DRIVERS,
|
||||||
|
} from '../../domain/value-objects/LeagueVisibility';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* League visibility/ranking mode.
|
||||||
|
* - 'ranked' (or legacy 'public'): Competitive, public, affects driver ratings. Min 10 drivers.
|
||||||
|
* - 'unranked' (or legacy 'private'): Casual with friends, no rating impact.
|
||||||
|
*/
|
||||||
|
export type LeagueVisibilityInput = 'ranked' | 'unranked' | 'public' | 'private';
|
||||||
|
|
||||||
export interface CreateLeagueWithSeasonAndScoringCommand {
|
export interface CreateLeagueWithSeasonAndScoringCommand {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
visibility: 'public' | 'private';
|
/**
|
||||||
|
* League visibility/ranking mode.
|
||||||
|
* - 'ranked' (or legacy 'public'): Competitive, public, affects ratings. Requires min 10 drivers.
|
||||||
|
* - 'unranked' (or legacy 'private'): Casual with friends, no rating impact.
|
||||||
|
*/
|
||||||
|
visibility: LeagueVisibilityInput;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
gameId: string;
|
gameId: string;
|
||||||
maxDrivers?: number;
|
maxDrivers?: number;
|
||||||
@@ -137,5 +153,20 @@ export class CreateLeagueWithSeasonAndScoringUseCase {
|
|||||||
if (command.maxDrivers !== undefined && command.maxDrivers <= 0) {
|
if (command.maxDrivers !== undefined && command.maxDrivers <= 0) {
|
||||||
throw new Error('maxDrivers must be greater than 0 when provided');
|
throw new Error('maxDrivers must be greater than 0 when provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate visibility-specific constraints
|
||||||
|
const visibility = LeagueVisibility.fromString(command.visibility);
|
||||||
|
|
||||||
|
if (visibility.isRanked()) {
|
||||||
|
// Ranked (public) leagues require minimum 10 drivers for competitive integrity
|
||||||
|
const driverCount = command.maxDrivers ?? 0;
|
||||||
|
if (driverCount < MIN_RANKED_LEAGUE_DRIVERS) {
|
||||||
|
throw new Error(
|
||||||
|
`Ranked leagues require at least ${MIN_RANKED_LEAGUE_DRIVERS} drivers. ` +
|
||||||
|
`Current setting: ${driverCount}. ` +
|
||||||
|
`For smaller groups, consider creating an Unranked (Friends) league instead.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
278
packages/racing/domain/services/ScheduleCalculator.test.ts
Normal file
278
packages/racing/domain/services/ScheduleCalculator.test.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { calculateRaceDates, getNextWeekday, type ScheduleConfig } from './ScheduleCalculator';
|
||||||
|
import type { Weekday } from '../value-objects/Weekday';
|
||||||
|
|
||||||
|
describe('ScheduleCalculator', () => {
|
||||||
|
describe('calculateRaceDates', () => {
|
||||||
|
describe('with empty or invalid input', () => {
|
||||||
|
it('should return empty array when weekdays is empty', () => {
|
||||||
|
// Given
|
||||||
|
const config: ScheduleConfig = {
|
||||||
|
weekdays: [],
|
||||||
|
frequency: 'weekly',
|
||||||
|
rounds: 8,
|
||||||
|
startDate: new Date('2024-01-01'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = calculateRaceDates(config);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result.raceDates).toEqual([]);
|
||||||
|
expect(result.seasonDurationWeeks).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when rounds is 0', () => {
|
||||||
|
// Given
|
||||||
|
const config: ScheduleConfig = {
|
||||||
|
weekdays: ['Sat'] as Weekday[],
|
||||||
|
frequency: 'weekly',
|
||||||
|
rounds: 0,
|
||||||
|
startDate: new Date('2024-01-01'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = calculateRaceDates(config);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result.raceDates).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when rounds is negative', () => {
|
||||||
|
// Given
|
||||||
|
const config: ScheduleConfig = {
|
||||||
|
weekdays: ['Sat'] as Weekday[],
|
||||||
|
frequency: 'weekly',
|
||||||
|
rounds: -5,
|
||||||
|
startDate: new Date('2024-01-01'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = calculateRaceDates(config);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result.raceDates).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('weekly scheduling', () => {
|
||||||
|
it('should schedule 8 races on Saturdays starting from a Saturday', () => {
|
||||||
|
// Given - January 6, 2024 is a Saturday
|
||||||
|
const config: ScheduleConfig = {
|
||||||
|
weekdays: ['Sat'] as Weekday[],
|
||||||
|
frequency: 'weekly',
|
||||||
|
rounds: 8,
|
||||||
|
startDate: new Date('2024-01-06'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = calculateRaceDates(config);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result.raceDates.length).toBe(8);
|
||||||
|
// All dates should be Saturdays
|
||||||
|
result.raceDates.forEach(date => {
|
||||||
|
expect(date.getDay()).toBe(6); // Saturday
|
||||||
|
});
|
||||||
|
// First race should be Jan 6
|
||||||
|
expect(result.raceDates[0].toISOString().split('T')[0]).toBe('2024-01-06');
|
||||||
|
// Last race should be 7 weeks later (Feb 24)
|
||||||
|
expect(result.raceDates[7].toISOString().split('T')[0]).toBe('2024-02-24');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should schedule races on multiple weekdays', () => {
|
||||||
|
// Given
|
||||||
|
const config: ScheduleConfig = {
|
||||||
|
weekdays: ['Wed', 'Sat'] as Weekday[],
|
||||||
|
frequency: 'weekly',
|
||||||
|
rounds: 8,
|
||||||
|
startDate: new Date('2024-01-01'), // Monday
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = calculateRaceDates(config);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result.raceDates.length).toBe(8);
|
||||||
|
// Should alternate between Wednesday and Saturday
|
||||||
|
result.raceDates.forEach(date => {
|
||||||
|
const day = date.getDay();
|
||||||
|
expect([3, 6]).toContain(day); // Wed=3, Sat=6
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should schedule 8 races on Sundays', () => {
|
||||||
|
// Given - January 7, 2024 is a Sunday
|
||||||
|
const config: ScheduleConfig = {
|
||||||
|
weekdays: ['Sun'] as Weekday[],
|
||||||
|
frequency: 'weekly',
|
||||||
|
rounds: 8,
|
||||||
|
startDate: new Date('2024-01-01'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = calculateRaceDates(config);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result.raceDates.length).toBe(8);
|
||||||
|
result.raceDates.forEach(date => {
|
||||||
|
expect(date.getDay()).toBe(0); // Sunday
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bi-weekly scheduling', () => {
|
||||||
|
it('should schedule races every 2 weeks on Saturdays', () => {
|
||||||
|
// Given - January 6, 2024 is a Saturday
|
||||||
|
const config: ScheduleConfig = {
|
||||||
|
weekdays: ['Sat'] as Weekday[],
|
||||||
|
frequency: 'everyNWeeks',
|
||||||
|
rounds: 4,
|
||||||
|
startDate: new Date('2024-01-06'),
|
||||||
|
intervalWeeks: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = calculateRaceDates(config);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result.raceDates.length).toBe(4);
|
||||||
|
// First race Jan 6
|
||||||
|
expect(result.raceDates[0].toISOString().split('T')[0]).toBe('2024-01-06');
|
||||||
|
// Second race 2 weeks later (Jan 20)
|
||||||
|
expect(result.raceDates[1].toISOString().split('T')[0]).toBe('2024-01-20');
|
||||||
|
// Third race 2 weeks later (Feb 3)
|
||||||
|
expect(result.raceDates[2].toISOString().split('T')[0]).toBe('2024-02-03');
|
||||||
|
// Fourth race 2 weeks later (Feb 17)
|
||||||
|
expect(result.raceDates[3].toISOString().split('T')[0]).toBe('2024-02-17');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with start and end dates', () => {
|
||||||
|
it('should evenly distribute races across the date range', () => {
|
||||||
|
// Given - 3 month season
|
||||||
|
const config: ScheduleConfig = {
|
||||||
|
weekdays: ['Sat'] as Weekday[],
|
||||||
|
frequency: 'weekly',
|
||||||
|
rounds: 8,
|
||||||
|
startDate: new Date('2024-01-06'),
|
||||||
|
endDate: new Date('2024-03-30'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = calculateRaceDates(config);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result.raceDates.length).toBe(8);
|
||||||
|
// First race should be at or near start
|
||||||
|
expect(result.raceDates[0].toISOString().split('T')[0]).toBe('2024-01-06');
|
||||||
|
// Races should be spread across the range, not consecutive weeks
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use all available days if fewer than rounds requested', () => {
|
||||||
|
// Given - short period with only 3 Saturdays
|
||||||
|
const config: ScheduleConfig = {
|
||||||
|
weekdays: ['Sat'] as Weekday[],
|
||||||
|
frequency: 'weekly',
|
||||||
|
rounds: 10,
|
||||||
|
startDate: new Date('2024-01-06'),
|
||||||
|
endDate: new Date('2024-01-21'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = calculateRaceDates(config);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
// Only 3 Saturdays in this range: Jan 6, 13, 20
|
||||||
|
expect(result.raceDates.length).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('season duration calculation', () => {
|
||||||
|
it('should calculate correct season duration in weeks', () => {
|
||||||
|
// Given
|
||||||
|
const config: ScheduleConfig = {
|
||||||
|
weekdays: ['Sat'] as Weekday[],
|
||||||
|
frequency: 'weekly',
|
||||||
|
rounds: 8,
|
||||||
|
startDate: new Date('2024-01-06'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = calculateRaceDates(config);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
// 8 races, 1 week apart = 7 weeks duration
|
||||||
|
expect(result.seasonDurationWeeks).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 duration for single race', () => {
|
||||||
|
// Given
|
||||||
|
const config: ScheduleConfig = {
|
||||||
|
weekdays: ['Sat'] as Weekday[],
|
||||||
|
frequency: 'weekly',
|
||||||
|
rounds: 1,
|
||||||
|
startDate: new Date('2024-01-06'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = calculateRaceDates(config);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result.raceDates.length).toBe(1);
|
||||||
|
expect(result.seasonDurationWeeks).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNextWeekday', () => {
|
||||||
|
it('should return next Saturday from a Monday', () => {
|
||||||
|
// Given - January 1, 2024 is a Monday
|
||||||
|
const fromDate = new Date('2024-01-01');
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = getNextWeekday(fromDate, 'Sat');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result.toISOString().split('T')[0]).toBe('2024-01-06');
|
||||||
|
expect(result.getDay()).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return next occurrence when already on that weekday', () => {
|
||||||
|
// Given - January 6, 2024 is a Saturday
|
||||||
|
const fromDate = new Date('2024-01-06');
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = getNextWeekday(fromDate, 'Sat');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
// Should return NEXT Saturday (7 days later), not same day
|
||||||
|
expect(result.toISOString().split('T')[0]).toBe('2024-01-13');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return next Sunday from a Friday', () => {
|
||||||
|
// Given - January 5, 2024 is a Friday
|
||||||
|
const fromDate = new Date('2024-01-05');
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = getNextWeekday(fromDate, 'Sun');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result.toISOString().split('T')[0]).toBe('2024-01-07');
|
||||||
|
expect(result.getDay()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return next Wednesday from a Thursday', () => {
|
||||||
|
// Given - January 4, 2024 is a Thursday
|
||||||
|
const fromDate = new Date('2024-01-04');
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = getNextWeekday(fromDate, 'Wed');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
// Next Wednesday is 6 days later
|
||||||
|
expect(result.toISOString().split('T')[0]).toBe('2024-01-10');
|
||||||
|
expect(result.getDay()).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
147
packages/racing/domain/services/ScheduleCalculator.ts
Normal file
147
packages/racing/domain/services/ScheduleCalculator.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import type { Weekday } from '../value-objects/Weekday';
|
||||||
|
|
||||||
|
export type RecurrenceStrategy = 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
|
||||||
|
|
||||||
|
export interface ScheduleConfig {
|
||||||
|
weekdays: Weekday[];
|
||||||
|
frequency: RecurrenceStrategy;
|
||||||
|
rounds: number;
|
||||||
|
startDate: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
intervalWeeks?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleResult {
|
||||||
|
raceDates: Date[];
|
||||||
|
seasonDurationWeeks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JavaScript getDay(): 0=Sunday, 1=Monday, 2=Tuesday, etc.
|
||||||
|
*/
|
||||||
|
const DAY_MAP: Record<Weekday, number> = {
|
||||||
|
'Sun': 0, 'Mon': 1, 'Tue': 2, 'Wed': 3, 'Thu': 4, 'Fri': 5, 'Sat': 6
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate race dates based on schedule configuration.
|
||||||
|
*
|
||||||
|
* If both startDate and endDate are provided, races are evenly distributed
|
||||||
|
* across the selected weekdays within that range.
|
||||||
|
*
|
||||||
|
* If only startDate is provided, races are scheduled according to the
|
||||||
|
* recurrence strategy (weekly or bi-weekly).
|
||||||
|
*/
|
||||||
|
export function calculateRaceDates(config: ScheduleConfig): ScheduleResult {
|
||||||
|
const { weekdays, frequency, rounds, startDate, endDate, intervalWeeks } = config;
|
||||||
|
const dates: Date[] = [];
|
||||||
|
|
||||||
|
if (weekdays.length === 0 || rounds <= 0) {
|
||||||
|
return { raceDates: [], seasonDurationWeeks: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert weekday names to day numbers for faster lookup
|
||||||
|
const selectedDayNumbers = new Set(weekdays.map(wd => DAY_MAP[wd]));
|
||||||
|
|
||||||
|
// If we have both start and end dates, evenly distribute races
|
||||||
|
if (endDate && endDate > startDate) {
|
||||||
|
const allPossibleDays: Date[] = [];
|
||||||
|
const currentDate = new Date(startDate);
|
||||||
|
currentDate.setHours(12, 0, 0, 0);
|
||||||
|
|
||||||
|
const endDateTime = new Date(endDate);
|
||||||
|
endDateTime.setHours(12, 0, 0, 0);
|
||||||
|
|
||||||
|
while (currentDate <= endDateTime) {
|
||||||
|
const dayOfWeek = currentDate.getDay();
|
||||||
|
if (selectedDayNumbers.has(dayOfWeek)) {
|
||||||
|
allPossibleDays.push(new Date(currentDate));
|
||||||
|
}
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evenly distribute the rounds across available days
|
||||||
|
const totalPossible = allPossibleDays.length;
|
||||||
|
if (totalPossible >= rounds) {
|
||||||
|
const spacing = totalPossible / rounds;
|
||||||
|
for (let i = 0; i < rounds; i++) {
|
||||||
|
const index = Math.min(Math.floor(i * spacing), totalPossible - 1);
|
||||||
|
dates.push(allPossibleDays[index]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not enough days - use all available
|
||||||
|
dates.push(...allPossibleDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasonDurationWeeks = dates.length > 1
|
||||||
|
? Math.ceil((dates[dates.length - 1].getTime() - dates[0].getTime()) / (7 * 24 * 60 * 60 * 1000))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return { raceDates: dates, seasonDurationWeeks };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule based on frequency (no end date)
|
||||||
|
const currentDate = new Date(startDate);
|
||||||
|
currentDate.setHours(12, 0, 0, 0);
|
||||||
|
let roundsScheduled = 0;
|
||||||
|
|
||||||
|
// Generate race dates for up to 2 years to ensure we can schedule all rounds
|
||||||
|
const maxDays = 365 * 2;
|
||||||
|
let daysChecked = 0;
|
||||||
|
const seasonStart = new Date(startDate);
|
||||||
|
seasonStart.setHours(12, 0, 0, 0);
|
||||||
|
|
||||||
|
while (roundsScheduled < rounds && daysChecked < maxDays) {
|
||||||
|
const dayOfWeek = currentDate.getDay();
|
||||||
|
const isSelectedDay = selectedDayNumbers.has(dayOfWeek);
|
||||||
|
|
||||||
|
// Calculate which week this is from the start
|
||||||
|
const daysSinceStart = Math.floor((currentDate.getTime() - seasonStart.getTime()) / (24 * 60 * 60 * 1000));
|
||||||
|
const currentWeek = Math.floor(daysSinceStart / 7);
|
||||||
|
|
||||||
|
if (isSelectedDay) {
|
||||||
|
let shouldRace = false;
|
||||||
|
|
||||||
|
if (frequency === 'weekly') {
|
||||||
|
// Weekly: race every week on selected days
|
||||||
|
shouldRace = true;
|
||||||
|
} else if (frequency === 'everyNWeeks') {
|
||||||
|
// Every N weeks: race only on matching week intervals
|
||||||
|
const interval = intervalWeeks ?? 2;
|
||||||
|
shouldRace = currentWeek % interval === 0;
|
||||||
|
} else {
|
||||||
|
// Default to weekly if frequency not set
|
||||||
|
shouldRace = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRace) {
|
||||||
|
dates.push(new Date(currentDate));
|
||||||
|
roundsScheduled++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1);
|
||||||
|
daysChecked++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasonDurationWeeks = dates.length > 1
|
||||||
|
? Math.ceil((dates[dates.length - 1].getTime() - dates[0].getTime()) / (7 * 24 * 60 * 60 * 1000))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return { raceDates: dates, seasonDurationWeeks };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next occurrence of a specific weekday from a given date.
|
||||||
|
*/
|
||||||
|
export function getNextWeekday(fromDate: Date, weekday: Weekday): Date {
|
||||||
|
const targetDay = DAY_MAP[weekday];
|
||||||
|
const result = new Date(fromDate);
|
||||||
|
result.setHours(12, 0, 0, 0);
|
||||||
|
|
||||||
|
const currentDay = result.getDay();
|
||||||
|
const daysUntilTarget = (targetDay - currentDay + 7) % 7 || 7;
|
||||||
|
|
||||||
|
result.setDate(result.getDate() + daysUntilTarget);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
176
packages/racing/domain/value-objects/GameConstraints.ts
Normal file
176
packages/racing/domain/value-objects/GameConstraints.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Domain Value Object: GameConstraints
|
||||||
|
*
|
||||||
|
* Represents game-specific constraints for leagues.
|
||||||
|
* Different sim racing games have different maximum grid sizes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface GameConstraintsData {
|
||||||
|
readonly maxDrivers: number;
|
||||||
|
readonly maxTeams: number;
|
||||||
|
readonly defaultMaxDrivers: number;
|
||||||
|
readonly minDrivers: number;
|
||||||
|
readonly supportsTeams: boolean;
|
||||||
|
readonly supportsMultiClass: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game-specific constraints for popular sim racing games
|
||||||
|
*/
|
||||||
|
const GAME_CONSTRAINTS: Record<string, GameConstraintsData> = {
|
||||||
|
iracing: {
|
||||||
|
maxDrivers: 64,
|
||||||
|
maxTeams: 32,
|
||||||
|
defaultMaxDrivers: 24,
|
||||||
|
minDrivers: 2,
|
||||||
|
supportsTeams: true,
|
||||||
|
supportsMultiClass: true,
|
||||||
|
},
|
||||||
|
acc: {
|
||||||
|
maxDrivers: 30,
|
||||||
|
maxTeams: 15,
|
||||||
|
defaultMaxDrivers: 24,
|
||||||
|
minDrivers: 2,
|
||||||
|
supportsTeams: true,
|
||||||
|
supportsMultiClass: false,
|
||||||
|
},
|
||||||
|
rf2: {
|
||||||
|
maxDrivers: 64,
|
||||||
|
maxTeams: 32,
|
||||||
|
defaultMaxDrivers: 24,
|
||||||
|
minDrivers: 2,
|
||||||
|
supportsTeams: true,
|
||||||
|
supportsMultiClass: true,
|
||||||
|
},
|
||||||
|
ams2: {
|
||||||
|
maxDrivers: 32,
|
||||||
|
maxTeams: 16,
|
||||||
|
defaultMaxDrivers: 20,
|
||||||
|
minDrivers: 2,
|
||||||
|
supportsTeams: true,
|
||||||
|
supportsMultiClass: true,
|
||||||
|
},
|
||||||
|
lmu: {
|
||||||
|
maxDrivers: 32,
|
||||||
|
maxTeams: 16,
|
||||||
|
defaultMaxDrivers: 24,
|
||||||
|
minDrivers: 2,
|
||||||
|
supportsTeams: true,
|
||||||
|
supportsMultiClass: true,
|
||||||
|
},
|
||||||
|
// Default for unknown games
|
||||||
|
default: {
|
||||||
|
maxDrivers: 32,
|
||||||
|
maxTeams: 16,
|
||||||
|
defaultMaxDrivers: 20,
|
||||||
|
minDrivers: 2,
|
||||||
|
supportsTeams: true,
|
||||||
|
supportsMultiClass: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export class GameConstraints {
|
||||||
|
readonly gameId: string;
|
||||||
|
readonly constraints: GameConstraintsData;
|
||||||
|
|
||||||
|
private constructor(gameId: string, constraints: GameConstraintsData) {
|
||||||
|
this.gameId = gameId;
|
||||||
|
this.constraints = constraints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get constraints for a specific game
|
||||||
|
*/
|
||||||
|
static forGame(gameId: string): GameConstraints {
|
||||||
|
const lowerId = gameId.toLowerCase();
|
||||||
|
const constraints = GAME_CONSTRAINTS[lowerId] ?? GAME_CONSTRAINTS.default;
|
||||||
|
return new GameConstraints(lowerId, constraints);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all supported game IDs
|
||||||
|
*/
|
||||||
|
static getSupportedGames(): string[] {
|
||||||
|
return Object.keys(GAME_CONSTRAINTS).filter(id => id !== 'default');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum drivers allowed for this game
|
||||||
|
*/
|
||||||
|
get maxDrivers(): number {
|
||||||
|
return this.constraints.maxDrivers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum teams allowed for this game
|
||||||
|
*/
|
||||||
|
get maxTeams(): number {
|
||||||
|
return this.constraints.maxTeams;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default driver count for new leagues
|
||||||
|
*/
|
||||||
|
get defaultMaxDrivers(): number {
|
||||||
|
return this.constraints.defaultMaxDrivers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum drivers required
|
||||||
|
*/
|
||||||
|
get minDrivers(): number {
|
||||||
|
return this.constraints.minDrivers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this game supports team-based leagues
|
||||||
|
*/
|
||||||
|
get supportsTeams(): boolean {
|
||||||
|
return this.constraints.supportsTeams;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this game supports multi-class racing
|
||||||
|
*/
|
||||||
|
get supportsMultiClass(): boolean {
|
||||||
|
return this.constraints.supportsMultiClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a driver count against game constraints
|
||||||
|
*/
|
||||||
|
validateDriverCount(count: number): { valid: boolean; error?: string } {
|
||||||
|
if (count < this.minDrivers) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Minimum ${this.minDrivers} drivers required`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (count > this.maxDrivers) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Maximum ${this.maxDrivers} drivers allowed for ${this.gameId.toUpperCase()}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a team count against game constraints
|
||||||
|
*/
|
||||||
|
validateTeamCount(count: number): { valid: boolean; error?: string } {
|
||||||
|
if (!this.supportsTeams) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `${this.gameId.toUpperCase()} does not support team-based leagues`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (count > this.maxTeams) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Maximum ${this.maxTeams} teams allowed for ${this.gameId.toUpperCase()}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
89
packages/racing/domain/value-objects/LeagueDescription.ts
Normal file
89
packages/racing/domain/value-objects/LeagueDescription.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Domain Value Object: LeagueDescription
|
||||||
|
*
|
||||||
|
* Represents a valid league description with validation rules.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LeagueDescriptionValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LEAGUE_DESCRIPTION_CONSTRAINTS = {
|
||||||
|
minLength: 20,
|
||||||
|
maxLength: 1000,
|
||||||
|
recommendedMinLength: 50,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export class LeagueDescription {
|
||||||
|
readonly value: string;
|
||||||
|
|
||||||
|
private constructor(value: string) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a league description without creating the value object
|
||||||
|
*/
|
||||||
|
static validate(value: string): LeagueDescriptionValidationResult {
|
||||||
|
const trimmed = value?.trim() ?? '';
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return { valid: false, error: 'Description is required — help drivers understand your league' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.length < LEAGUE_DESCRIPTION_CONSTRAINTS.minLength) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Description must be at least ${LEAGUE_DESCRIPTION_CONSTRAINTS.minLength} characters — tell drivers what makes your league special`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.length > LEAGUE_DESCRIPTION_CONSTRAINTS.maxLength) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Description must be ${LEAGUE_DESCRIPTION_CONSTRAINTS.maxLength} characters or less`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if description meets recommended length for better engagement
|
||||||
|
*/
|
||||||
|
static isRecommendedLength(value: string): boolean {
|
||||||
|
const trimmed = value?.trim() ?? '';
|
||||||
|
return trimmed.length >= LEAGUE_DESCRIPTION_CONSTRAINTS.recommendedMinLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a LeagueDescription from a string value
|
||||||
|
*/
|
||||||
|
static create(value: string): LeagueDescription {
|
||||||
|
const validation = this.validate(value);
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new Error(validation.error);
|
||||||
|
}
|
||||||
|
return new LeagueDescription(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to create a LeagueDescription, returning null if invalid
|
||||||
|
*/
|
||||||
|
static tryCreate(value: string): LeagueDescription | null {
|
||||||
|
const validation = this.validate(value);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new LeagueDescription(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: LeagueDescription): boolean {
|
||||||
|
return this.value === other.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
102
packages/racing/domain/value-objects/LeagueName.ts
Normal file
102
packages/racing/domain/value-objects/LeagueName.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Domain Value Object: LeagueName
|
||||||
|
*
|
||||||
|
* Represents a valid league name with validation rules.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LeagueNameValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LEAGUE_NAME_CONSTRAINTS = {
|
||||||
|
minLength: 3,
|
||||||
|
maxLength: 64,
|
||||||
|
pattern: /^[a-zA-Z0-9].*$/, // Must start with alphanumeric
|
||||||
|
forbiddenPatterns: [
|
||||||
|
/^\s/, // No leading whitespace
|
||||||
|
/\s$/, // No trailing whitespace
|
||||||
|
/\s{2,}/, // No multiple consecutive spaces
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export class LeagueName {
|
||||||
|
readonly value: string;
|
||||||
|
|
||||||
|
private constructor(value: string) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a league name without creating the value object
|
||||||
|
*/
|
||||||
|
static validate(value: string): LeagueNameValidationResult {
|
||||||
|
const trimmed = value?.trim() ?? '';
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return { valid: false, error: 'League name is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.length < LEAGUE_NAME_CONSTRAINTS.minLength) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `League name must be at least ${LEAGUE_NAME_CONSTRAINTS.minLength} characters`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.length > LEAGUE_NAME_CONSTRAINTS.maxLength) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `League name must be ${LEAGUE_NAME_CONSTRAINTS.maxLength} characters or less`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!LEAGUE_NAME_CONSTRAINTS.pattern.test(trimmed)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'League name must start with a letter or number',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const forbidden of LEAGUE_NAME_CONSTRAINTS.forbiddenPatterns) {
|
||||||
|
if (forbidden.test(value)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'League name cannot have leading/trailing spaces or multiple consecutive spaces',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a LeagueName from a string value
|
||||||
|
*/
|
||||||
|
static create(value: string): LeagueName {
|
||||||
|
const validation = this.validate(value);
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new Error(validation.error);
|
||||||
|
}
|
||||||
|
return new LeagueName(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to create a LeagueName, returning null if invalid
|
||||||
|
*/
|
||||||
|
static tryCreate(value: string): LeagueName | null {
|
||||||
|
const validation = this.validate(value);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new LeagueName(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: LeagueName): boolean {
|
||||||
|
return this.value === other.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
129
packages/racing/domain/value-objects/LeagueVisibility.ts
Normal file
129
packages/racing/domain/value-objects/LeagueVisibility.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Domain Value Object: LeagueVisibility
|
||||||
|
*
|
||||||
|
* Represents the visibility and ranking status of a league.
|
||||||
|
*
|
||||||
|
* - 'ranked' (public): Competitive leagues visible to everyone, affects driver ratings.
|
||||||
|
* Requires minimum 10 players to ensure competitive integrity.
|
||||||
|
* - 'unranked' (private): Casual leagues for friends/private groups, no rating impact.
|
||||||
|
* Can have any number of players.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type LeagueVisibilityType = 'ranked' | 'unranked';
|
||||||
|
|
||||||
|
export interface LeagueVisibilityConstraints {
|
||||||
|
readonly minDrivers: number;
|
||||||
|
readonly isPubliclyVisible: boolean;
|
||||||
|
readonly affectsRatings: boolean;
|
||||||
|
readonly requiresApproval: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VISIBILITY_CONSTRAINTS: Record<LeagueVisibilityType, LeagueVisibilityConstraints> = {
|
||||||
|
ranked: {
|
||||||
|
minDrivers: 10,
|
||||||
|
isPubliclyVisible: true,
|
||||||
|
affectsRatings: true,
|
||||||
|
requiresApproval: false, // Anyone can join public leagues
|
||||||
|
},
|
||||||
|
unranked: {
|
||||||
|
minDrivers: 2,
|
||||||
|
isPubliclyVisible: false,
|
||||||
|
affectsRatings: false,
|
||||||
|
requiresApproval: true, // Private leagues require invite/approval
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export class LeagueVisibility {
|
||||||
|
readonly type: LeagueVisibilityType;
|
||||||
|
readonly constraints: LeagueVisibilityConstraints;
|
||||||
|
|
||||||
|
private constructor(type: LeagueVisibilityType) {
|
||||||
|
this.type = type;
|
||||||
|
this.constraints = VISIBILITY_CONSTRAINTS[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
static ranked(): LeagueVisibility {
|
||||||
|
return new LeagueVisibility('ranked');
|
||||||
|
}
|
||||||
|
|
||||||
|
static unranked(): LeagueVisibility {
|
||||||
|
return new LeagueVisibility('unranked');
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromString(value: string): LeagueVisibility {
|
||||||
|
// Support both old ('public'/'private') and new ('ranked'/'unranked') terminology
|
||||||
|
if (value === 'ranked' || value === 'public') {
|
||||||
|
return LeagueVisibility.ranked();
|
||||||
|
}
|
||||||
|
if (value === 'unranked' || value === 'private') {
|
||||||
|
return LeagueVisibility.unranked();
|
||||||
|
}
|
||||||
|
throw new Error(`Invalid league visibility: ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that the given driver count meets the minimum requirement
|
||||||
|
* for this visibility type.
|
||||||
|
*/
|
||||||
|
validateDriverCount(driverCount: number): { valid: boolean; error?: string } {
|
||||||
|
if (driverCount < this.constraints.minDrivers) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `${this.type === 'ranked' ? 'Ranked' : 'Unranked'} leagues require at least ${this.constraints.minDrivers} drivers`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if this is a ranked/public league
|
||||||
|
*/
|
||||||
|
isRanked(): boolean {
|
||||||
|
return this.type === 'ranked';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if this is an unranked/private league
|
||||||
|
*/
|
||||||
|
isUnranked(): boolean {
|
||||||
|
return this.type === 'unranked';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable label for UI display
|
||||||
|
*/
|
||||||
|
getLabel(): string {
|
||||||
|
return this.type === 'ranked' ? 'Ranked (Public)' : 'Unranked (Friends)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Short description for UI tooltips
|
||||||
|
*/
|
||||||
|
getDescription(): string {
|
||||||
|
return this.type === 'ranked'
|
||||||
|
? 'Competitive league visible to everyone. Results affect driver ratings.'
|
||||||
|
: 'Private league for friends. Results do not affect ratings.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to string for serialization
|
||||||
|
*/
|
||||||
|
toString(): LeagueVisibilityType {
|
||||||
|
return this.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For backward compatibility with existing 'public'/'private' terminology
|
||||||
|
*/
|
||||||
|
toLegacyString(): 'public' | 'private' {
|
||||||
|
return this.type === 'ranked' ? 'public' : 'private';
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: LeagueVisibility): boolean {
|
||||||
|
return this.type === other.type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export constants for validation
|
||||||
|
export const MIN_RANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.ranked.minDrivers;
|
||||||
|
export const MIN_UNRANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.unranked.minDrivers;
|
||||||
Reference in New Issue
Block a user