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

View File

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