move static data

This commit is contained in:
2025-12-26 00:20:53 +01:00
parent c977defd6a
commit b6cbb81388
63 changed files with 1482 additions and 418 deletions

View File

@@ -22,9 +22,10 @@ import {
Users,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { FormEvent, useCallback, useEffect, useState } from 'react';
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel';
import { useCreateLeagueWizard } from '@/hooks/useLeagueWizardService';
import { useLeagueScoringPresets } from '@/hooks/useLeagueScoringPresets';
import { LeagueBasicsSection } from './LeagueBasicsSection';
@@ -494,78 +495,29 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
}
};
// Handler for scoring preset selection - delegates to application-level config helper
// Handler for scoring preset selection (timings default from API)
const handleScoringPresetChange = (patternId: string) => {
setForm((prev) => {
// Convert to LeagueWizardFormData for the command model
const formData: any = {
leagueId: prev.leagueId || '',
basics: {
name: prev.basics?.name || '',
description: prev.basics?.description || '',
visibility: (prev.basics?.visibility as 'public' | 'private' | 'unlisted') || 'public',
gameId: prev.basics?.gameId || 'iracing',
},
structure: {
mode: (prev.structure?.mode as 'solo' | 'fixedTeams') || 'solo',
maxDrivers: prev.structure?.maxDrivers || 24,
maxTeams: prev.structure?.maxTeams || 0,
driversPerTeam: prev.structure?.driversPerTeam || 0,
},
championships: {
enableDriverChampionship: prev.championships?.enableDriverChampionship ?? true,
enableTeamChampionship: prev.championships?.enableTeamChampionship ?? false,
enableNationsChampionship: prev.championships?.enableNationsChampionship ?? false,
enableTrophyChampionship: prev.championships?.enableTrophyChampionship ?? false,
},
scoring: {
patternId: prev.scoring?.patternId,
customScoringEnabled: prev.scoring?.customScoringEnabled ?? false,
},
dropPolicy: {
strategy: (prev.dropPolicy?.strategy as 'none' | 'bestNResults' | 'dropWorstN') || 'bestNResults',
n: prev.dropPolicy?.n || 6,
},
timings: {
practiceMinutes: prev.timings?.practiceMinutes || 0,
qualifyingMinutes: prev.timings?.qualifyingMinutes || 0,
sprintRaceMinutes: prev.timings?.sprintRaceMinutes || 0,
mainRaceMinutes: prev.timings?.mainRaceMinutes || 0,
sessionCount: prev.timings?.sessionCount || 0,
roundsPlanned: prev.timings?.roundsPlanned || 0,
raceDayOfWeek: prev.timings?.raceDayOfWeek || 0,
raceTimeUtc: prev.timings?.raceTimeUtc || '',
weekdays: (prev.timings?.weekdays as Weekday[]) || [],
recurrenceStrategy: prev.timings?.recurrenceStrategy || '',
timezoneId: prev.timings?.timezoneId || '',
seasonStartDate: prev.timings?.seasonStartDate || '',
},
stewarding: {
decisionMode: (prev.stewarding?.decisionMode as 'owner_only' | 'admin_vote' | 'steward_panel') || 'admin_only',
requiredVotes: prev.stewarding?.requiredVotes || 2,
requireDefense: prev.stewarding?.requireDefense ?? false,
defenseTimeLimit: prev.stewarding?.defenseTimeLimit || 48,
voteTimeLimit: prev.stewarding?.voteTimeLimit || 72,
protestDeadlineHours: prev.stewarding?.protestDeadlineHours || 48,
stewardingClosesHours: prev.stewarding?.stewardingClosesHours || 168,
notifyAccusedOnProtest: prev.stewarding?.notifyAccusedOnProtest ?? true,
notifyOnVoteRequired: prev.stewarding?.notifyOnVoteRequired ?? true,
},
};
const selectedPreset = presets.find((p) => p.id === patternId);
const updated = LeagueWizardCommandModel.applyScoringPresetToConfig(formData, patternId);
// Convert back to LeagueWizardFormModel
return {
basics: updated.basics,
structure: updated.structure,
championships: updated.championships,
scoring: updated.scoring,
dropPolicy: updated.dropPolicy,
timings: updated.timings,
stewarding: updated.stewarding,
seasonName: prev.seasonName,
} as LeagueWizardFormModel;
...prev,
scoring: {
...prev.scoring,
patternId,
customScoringEnabled: false,
},
timings: selectedPreset
? {
...prev.timings,
practiceMinutes: prev.timings?.practiceMinutes ?? selectedPreset.defaultTimings.practiceMinutes,
qualifyingMinutes: prev.timings?.qualifyingMinutes ?? selectedPreset.defaultTimings.qualifyingMinutes,
sprintRaceMinutes: prev.timings?.sprintRaceMinutes ?? selectedPreset.defaultTimings.sprintRaceMinutes,
mainRaceMinutes: prev.timings?.mainRaceMinutes ?? selectedPreset.defaultTimings.mainRaceMinutes,
sessionCount: selectedPreset.defaultTimings.sessionCount,
}
: prev.timings,
};
});
};

View File

@@ -8,8 +8,6 @@ import Card from "../ui/Card";
import Button from "../ui/Button";
import { Clock, Grid3x3, TrendingDown, AlertCircle, Filter, Flag } from "lucide-react";
type PenaltyType = "time_penalty" | "grid_penalty" | "points_deduction" | "disqualification" | "warning" | "license_points";
interface PenaltyHistoryListProps {
protests: ProtestViewModel[];
races: Record<string, RaceViewModel>;

View File

@@ -1,6 +1,8 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { usePenaltyTypesReference } from "@/hooks/usePenaltyTypesReference";
import type { PenaltyValueKindDTO } from "@/lib/types/PenaltyTypesReferenceDTO";
import { ProtestViewModel } from "../../lib/view-models/ProtestViewModel";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
@@ -21,7 +23,7 @@ import {
FileWarning,
} from "lucide-react";
type PenaltyType = "time_penalty" | "grid_penalty" | "points_deduction" | "disqualification" | "warning" | "license_points" | "probation" | "fine" | "race_ban";
type PenaltyType = string;
interface ReviewProtestModalProps {
protest: ProtestViewModel | null;
@@ -94,25 +96,63 @@ export function ReviewProtestModal({
}
};
const getPenaltyLabel = (type: PenaltyType) => {
const getPenaltyName = (type: PenaltyType) => {
switch (type) {
case "time_penalty":
return "seconds";
return "Time Penalty";
case "grid_penalty":
return "grid positions";
return "Grid Penalty";
case "points_deduction":
return "points";
return "Points Deduction";
case "disqualification":
return "Disqualification";
case "warning":
return "Warning";
case "license_points":
return "points";
return "License Points";
case "probation":
return "Probation";
case "fine":
return "points";
return "Fine";
case "race_ban":
return "races";
return "Race Ban";
default:
return type.replaceAll("_", " ");
}
};
const getPenaltyValueLabel = (valueKind: PenaltyValueKindDTO): string => {
switch (valueKind) {
case "seconds":
return "seconds";
case "grid_positions":
return "grid positions";
case "points":
return "points";
case "races":
return "races";
case "none":
return "";
}
};
const getPenaltyDefaultValue = (type: PenaltyType, valueKind: PenaltyValueKindDTO): number => {
if (type === "license_points") return 2;
if (type === "race_ban") return 1;
switch (valueKind) {
case "seconds":
return 5;
case "grid_positions":
return 3;
case "points":
return 5;
case "races":
return 1;
case "none":
return 0;
}
};
const getPenaltyColor = (type: PenaltyType) => {
switch (type) {
case "time_penalty":
@@ -138,6 +178,25 @@ export function ReviewProtestModal({
}
};
const { data: penaltyTypesReference, isLoading: penaltyTypesLoading } = usePenaltyTypesReference();
const penaltyOptions = useMemo(() => {
const refs = penaltyTypesReference?.penaltyTypes ?? [];
return refs.map((ref) => ({
type: ref.type as PenaltyType,
name: getPenaltyName(ref.type),
requiresValue: ref.requiresValue,
valueLabel: getPenaltyValueLabel(ref.valueKind),
defaultValue: getPenaltyDefaultValue(ref.type, ref.valueKind),
Icon: getPenaltyIcon(ref.type),
colorClass: getPenaltyColor(ref.type),
}));
}, [penaltyTypesReference]);
const selectedPenalty = useMemo(() => {
return penaltyOptions.find((p) => p.type === penaltyType);
}, [penaltyOptions, penaltyType]);
if (showConfirmation) {
return (
<Modal title="Confirm Decision" isOpen={true} onOpenChange={() => setShowConfirmation(false)}>
@@ -160,7 +219,9 @@ export function ReviewProtestModal({
<h3 className="text-xl font-bold text-white">Confirm Decision</h3>
<p className="text-gray-400 mt-2">
{decision === "accept"
? `Issue ${penaltyValue} ${getPenaltyLabel(penaltyType)} penalty?`
? (selectedPenalty?.requiresValue
? `Issue ${penaltyValue} ${selectedPenalty.valueLabel} penalty?`
: `Issue ${selectedPenalty?.name ?? penaltyType} penalty?`)
: "Reject this protest?"}
</p>
</div>
@@ -300,43 +361,39 @@ export function ReviewProtestModal({
<label className="text-sm font-medium text-gray-400 mb-2 block">
Penalty Type
</label>
<div className="grid grid-cols-3 gap-2">
{[
{ type: "time_penalty" as PenaltyType, label: "Time Penalty" },
{ type: "grid_penalty" as PenaltyType, label: "Grid Penalty" },
{ type: "points_deduction" as PenaltyType, label: "Points Deduction" },
{ type: "disqualification" as PenaltyType, label: "Disqualification" },
{ type: "warning" as PenaltyType, label: "Warning" },
{ type: "license_points" as PenaltyType, label: "License Points" },
{ type: "probation" as PenaltyType, label: "Probation" },
{ type: "fine" as PenaltyType, label: "Fine" },
{ type: "race_ban" as PenaltyType, label: "Race Ban" },
].map(({ type, label }) => {
const Icon = getPenaltyIcon(type);
const colorClass = getPenaltyColor(type);
const isSelected = penaltyType === type;
return (
<button
key={type}
onClick={() => setPenaltyType(type)}
className={`p-3 rounded-lg border transition-all ${
isSelected
? `${colorClass} border-2`
: "border-charcoal-outline hover:border-gray-600 bg-iron-gray/50"
}`}
>
<Icon className={`h-5 w-5 mx-auto mb-1 ${isSelected ? '' : 'text-gray-400'}`} />
<p className={`text-xs font-medium ${isSelected ? '' : 'text-gray-400'}`}>{label}</p>
</button>
);
})}
</div>
{penaltyTypesLoading ? (
<div className="text-sm text-gray-500">Loading penalty types</div>
) : (
<div className="grid grid-cols-3 gap-2">
{penaltyOptions.map(({ type, name, Icon, colorClass, defaultValue }) => {
const isSelected = penaltyType === type;
return (
<button
key={type}
onClick={() => {
setPenaltyType(type);
setPenaltyValue(defaultValue);
}}
className={`p-3 rounded-lg border transition-all ${
isSelected
? `${colorClass} border-2`
: "border-charcoal-outline hover:border-gray-600 bg-iron-gray/50"
}`}
>
<Icon className={`h-5 w-5 mx-auto mb-1 ${isSelected ? "" : "text-gray-400"}`} />
<p className={`text-xs font-medium ${isSelected ? "" : "text-gray-400"}`}>{name}</p>
</button>
);
})}
</div>
)}
</div>
{['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(penaltyType) && (
{selectedPenalty?.requiresValue && (
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
Penalty Value ({getPenaltyLabel(penaltyType)})
Penalty Value ({selectedPenalty.valueLabel})
</label>
<input
type="number"

View File

@@ -1,5 +1,6 @@
'use client';
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';

View File

@@ -4,7 +4,7 @@ import { useAuth } from '@/lib/auth/AuthContext';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { BarChart3, Building2, ChevronDown, CreditCard, Handshake, LogOut, Megaphone, Paintbrush, Settings, TrendingUp, Trophy } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';

View File

@@ -0,0 +1,22 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`UserPill > renders auth links when there is no session 1`] = `
<div>
<div
class="flex items-center gap-2"
>
<a
class="inline-flex items-center gap-2 rounded-full bg-iron-gray border border-charcoal-outline px-4 py-1.5 text-xs font-medium text-gray-300 hover:text-white hover:border-gray-500 transition-all"
href="/auth/login"
>
Sign In
</a>
<a
class="inline-flex items-center gap-2 rounded-full bg-primary-blue px-4 py-1.5 text-xs font-semibold text-white shadow-[0_0_12px_rgba(25,140,255,0.5)] hover:bg-primary-blue/90 hover:shadow-[0_0_18px_rgba(25,140,255,0.8)] transition-all"
href="/auth/signup"
>
Get Started
</a>
</div>
</div>
`;

View File

@@ -172,22 +172,26 @@ export default function SponsorWorkflowMockup() {
transition={{ duration: 0.3 }}
className="mt-8 pt-6 border-t border-charcoal-outline"
>
<div className="flex items-center justify-center gap-3">
<div className={`w-8 h-8 rounded-lg bg-iron-gray flex items-center justify-center`}>
{(() => {
const Icon = WORKFLOW_STEPS[activeStep].icon;
return <Icon className={`w-4 h-4 ${WORKFLOW_STEPS[activeStep].color}`} />;
})()}
</div>
<div className="text-left">
<p className="text-sm text-gray-400">
Step {activeStep + 1} of {WORKFLOW_STEPS.length}
</p>
<p className="text-white font-medium">
{WORKFLOW_STEPS[activeStep].title}
</p>
</div>
</div>
{(() => {
const currentStep = WORKFLOW_STEPS[activeStep] ?? WORKFLOW_STEPS[0];
if (!currentStep) return null;
const Icon = currentStep.icon;
return (
<div className="flex items-center justify-center gap-3">
<div className="w-8 h-8 rounded-lg bg-iron-gray flex items-center justify-center">
<Icon className={`w-4 h-4 ${currentStep.color}`} />
</div>
<div className="text-left">
<p className="text-sm text-gray-400">
Step {activeStep + 1} of {WORKFLOW_STEPS.length}
</p>
<p className="text-white font-medium">{currentStep.title}</p>
</div>
</div>
);
})()}
</motion.div>
</AnimatePresence>
</div>