Files
gridpilot.gg/apps/website/components/leagues/LeagueScoringSection.tsx
2025-12-05 12:24:38 +01:00

489 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}