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