website refactor

This commit is contained in:
2026-01-19 02:14:53 +01:00
parent 489c5f7858
commit a8731e6937
70 changed files with 2908 additions and 2423 deletions

View File

@@ -4,12 +4,9 @@ import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { AdminDashboardTemplate } from '@/templates/AdminDashboardTemplate';
import { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface AdminDashboardWrapperProps {
initialViewData: AdminDashboardViewData;
}
export function AdminDashboardWrapper({ initialViewData }: AdminDashboardWrapperProps) {
export function AdminDashboardWrapper({ viewData }: ClientWrapperProps<AdminDashboardViewData>) {
const router = useRouter();
// UI state (not business logic)
@@ -24,7 +21,7 @@ export function AdminDashboardWrapper({ initialViewData }: AdminDashboardWrapper
return (
<AdminDashboardTemplate
viewData={initialViewData}
viewData={viewData}
onRefresh={handleRefresh}
isLoading={loading}
/>

View File

@@ -6,13 +6,10 @@ import { AdminUsersTemplate } from '@/templates/AdminUsersTemplate';
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
import { updateUserStatus, deleteUser } from '@/app/actions/adminActions';
import { routes } from '@/lib/routing/RouteConfig';
import { ConfirmDialog } from '@/ui/ConfirmDialog';
import { SharedConfirmDialog } from '@/components/shared/UIComponents';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface AdminUsersWrapperProps {
initialViewData: AdminUsersViewData;
}
export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
export function AdminUsersWrapper({ viewData }: ClientWrapperProps<AdminUsersViewData>) {
const router = useRouter();
const searchParams = useSearchParams();
@@ -38,12 +35,12 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
}, []);
const handleSelectAll = useCallback(() => {
if (selectedUserIds.length === initialViewData.users.length) {
if (selectedUserIds.length === viewData.users.length) {
setSelectedUserIds([]);
} else {
setSelectedUserIds(initialViewData.users.map(u => u.id));
setSelectedUserIds(viewData.users.map(u => u.id));
}
}, [selectedUserIds.length, initialViewData.users]);
}, [selectedUserIds.length, viewData.users]);
const handleClearSelection = useCallback(() => {
setSelectedUserIds([]);
@@ -132,7 +129,7 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
return (
<>
<AdminUsersTemplate
viewData={initialViewData}
viewData={viewData}
onRefresh={handleRefresh}
onSearch={handleSearch}
onFilterRole={handleFilterRole}
@@ -151,7 +148,7 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
onSelectAll={handleSelectAll}
onClearSelection={handleClearSelection}
/>
<ConfirmDialog
<SharedConfirmDialog
isOpen={!!userToDelete}
onClose={() => setUserToDelete(null)}
onConfirm={confirmDeleteUser}

View File

@@ -1,51 +1,26 @@
'use client';
import { useAuth } from '@/components/auth/AuthContext';
import { LeagueReviewSummary } from '@/components/leagues/LeagueReviewSummary';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Input } from '@/ui/Input';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import {
AlertCircle,
Award,
Calendar,
Check,
CheckCircle2,
ChevronLeft,
ChevronRight,
FileText,
Loader2,
Scale,
Sparkles,
Trophy,
Users,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { FormEvent, useCallback, useEffect, useState } from 'react';
import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel';
import { LeagueBasicsSection } from '@/components/leagues/LeagueBasicsSection';
import { LeagueDropSection } from '@/components/leagues/LeagueDropSection';
import {
ChampionshipsSection,
ScoringPatternSection
} from '@/components/leagues/LeagueScoringSection';
import { LeagueStewardingSection } from '@/components/leagues/LeagueStewardingSection';
import { LeagueStructureSection } from '@/components/leagues/LeagueStructureSection';
import { LeagueTimingsSection } from '@/components/leagues/LeagueTimingsSection';
import { LeagueVisibilitySection } from '@/components/leagues/LeagueVisibilitySection';
import { useLeagueScoringPresets } from "@/hooks/useLeagueScoringPresets";
import { useCreateLeagueWizard } from "@/hooks/useLeagueWizardService";
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { Weekday } from '@/lib/types/Weekday';
import type { WizardErrors } from '@/lib/types/WizardErrors';
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
import { CreateLeagueWizardTemplate, Step } from '@/templates/CreateLeagueWizardTemplate';
import {
Award,
Calendar,
CheckCircle2,
FileText,
Scale,
Trophy,
Users
} from 'lucide-react';
// ============================================================================
// LOCAL STORAGE PERSISTENCE
@@ -54,16 +29,14 @@ import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScori
const STORAGE_KEY = 'gridpilot_league_wizard_draft';
const STORAGE_HIGHEST_STEP_KEY = 'gridpilot_league_wizard_highest_step';
// TODO there is a better place for this
function saveFormToStorage(form: LeagueWizardFormModel): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
// Ignore storage errors (quota exceeded, etc.)
// Ignore storage errors
}
}
// TODO there is a better place for this
function loadFormFromStorage(): LeagueWizardFormModel | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
@@ -110,8 +83,6 @@ function getHighestStep(): number {
}
}
type Step = 1 | 2 | 3 | 4 | 5 | 6 | 7;
type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'stewarding' | 'review';
type LeagueWizardFormModel = LeagueConfigFormModel & {
@@ -125,44 +96,29 @@ interface CreateLeagueWizardProps {
function stepNameToStep(stepName: StepName): Step {
switch (stepName) {
case 'basics':
return 1;
case 'visibility':
return 2;
case 'structure':
return 3;
case 'schedule':
return 4;
case 'scoring':
return 5;
case 'stewarding':
return 6;
case 'review':
return 7;
case 'basics': return 1;
case 'visibility': return 2;
case 'structure': return 3;
case 'schedule': return 4;
case 'scoring': return 5;
case 'stewarding': return 6;
case 'review': return 7;
}
}
function stepToStepName(step: Step): StepName {
switch (step) {
case 1:
return 'basics';
case 2:
return 'visibility';
case 3:
return 'structure';
case 4:
return 'schedule';
case 5:
return 'scoring';
case 6:
return 'stewarding';
case 7:
return 'review';
case 1: return 'basics';
case 2: return 'visibility';
case 3: return 'structure';
case 4: return 'schedule';
case 5: return 'scoring';
case 6: return 'stewarding';
case 7: return 'review';
}
}
function getDefaultSeasonStartDate(): string {
// Default to next Saturday
const now = new Date();
const daysUntilSaturday = (6 - now.getDay() + 7) % 7 || 7;
const nextSaturday = new Date(now);
@@ -220,7 +176,6 @@ function createDefaultForm(): LeagueWizardFormModel {
mainRaceMinutes: 40,
sessionCount: 2,
roundsPlanned: 8,
// Default to Saturday races, weekly, starting next week
weekdays: ['Sat'] as Weekday[],
recurrenceStrategy: 'weekly' as const,
timezoneId: 'UTC',
@@ -253,12 +208,10 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
const [highestCompletedStep, setHighestCompletedStep] = useState(1);
const [isHydrated, setIsHydrated] = useState(false);
// Initialize form from localStorage or defaults
const [form, setForm] = useState<LeagueWizardFormModel>(() =>
createDefaultForm(),
);
// Hydrate from localStorage on mount
useEffect(() => {
const stored = loadFormFromStorage();
if (stored) {
@@ -268,14 +221,12 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
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);
@@ -283,13 +234,10 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
}
}, [step, isHydrated]);
// Use the react-query hook for scoring presets
const { data: queryPresets, error: presetsError } = useLeagueScoringPresets();
// Sync presets from query to local state
useEffect(() => {
if (queryPresets) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setPresets(queryPresets as any);
const firstPreset = queryPresets[0];
if (firstPreset && !form.scoring?.patternId) {
@@ -306,7 +254,6 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
}
}, [queryPresets, form.scoring?.patternId]);
// Handle presets error
useEffect(() => {
if (presetsError) {
setErrors((prev) => ({
@@ -316,12 +263,9 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
}
}, [presetsError]);
// Use the create league mutation
const createLeagueMutation = useCreateLeagueWizard();
const validateStep = (currentStep: Step): boolean => {
// Convert form to LeagueWizardFormData for validation
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const formData: any = {
leagueId: form.leagueId || '',
basics: {
@@ -371,7 +315,6 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stepErrors = LeagueWizardCommandModel.validateLeagueWizardStep(formData, currentStep as any);
setErrors((prev) => ({
...prev,
@@ -395,7 +338,6 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
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));
@@ -415,8 +357,6 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
return;
}
// Convert form to LeagueWizardFormData for validation
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const formData: any = {
leagueId: form.leagueId || '',
basics: {
@@ -484,17 +424,11 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
});
try {
// Use the mutation to create the league
const result = await createLeagueMutation.mutateAsync({ form, ownerId });
// Clear the draft on successful creation
clearFormStorage();
// Navigate to the new league
router.push(`/leagues/${result.leagueId}`);
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to create league';
const message = err instanceof Error ? err.message : 'Failed to create league';
setErrors((prev) => ({
...prev,
submit: message,
@@ -503,7 +437,6 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
}
};
// Handler for scoring preset selection (timings default from API)
const handleScoringPresetChange = (patternId: string) => {
setForm((prev) => {
const selectedPreset = presets.find((p) => p.id === patternId);
@@ -541,460 +474,65 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
const getStepTitle = (currentStep: Step): string => {
switch (currentStep) {
case 1:
return 'Name your league';
case 2:
return 'Choose your destiny';
case 3:
return 'Choose the structure';
case 4:
return 'Set the schedule';
case 5:
return 'Scoring & championships';
case 6:
return 'Stewarding & protests';
case 7:
return 'Review & create';
default:
return '';
case 1: return 'Name your league';
case 2: return 'Choose your destiny';
case 3: return 'Choose the structure';
case 4: return 'Set the schedule';
case 5: return 'Scoring & championships';
case 6: return 'Stewarding & protests';
case 7: return 'Review & create';
default: return '';
}
};
const getStepSubtitle = (currentStep: Step): string => {
switch (currentStep) {
case 1:
return 'Give your league a memorable name and tell your story.';
case 2:
return 'Will you compete for global rankings or race with friends?';
case 3:
return 'Define how races in this season will run.';
case 4:
return 'Plan when this seasons races happen.';
case 5:
return 'Choose how points and drop scores work for this season.';
case 6:
return 'Set how protests and stewarding work for this season.';
case 7:
return 'Review your league and first season before launching.';
default:
return '';
case 1: return 'Give your league a memorable name and tell your story.';
case 2: return 'Will you compete for global rankings or race with friends?';
case 3: return 'Define how races in this season will run.';
case 4: return 'Plan when this seasons races happen.';
case 5: return 'Choose how points and drop scores work for this season.';
case 6: return 'Set how protests and stewarding work for this season.';
case 7: return 'Review your league and first season before launching.';
default: return '';
}
};
const getStepContextLabel = (currentStep: Step): string => {
if (currentStep === 1 || currentStep === 2) {
return 'League setup';
}
if (currentStep >= 3 && currentStep <= 6) {
return 'Season setup';
}
if (currentStep === 1 || currentStep === 2) return 'League setup';
if (currentStep >= 3 && currentStep <= 6) return 'Season setup';
return 'League & Season summary';
};
const currentStepData = steps.find((s) => s.id === step);
const CurrentStepIcon = currentStepData?.icon ?? FileText;
return (
<Box as="form" onSubmit={handleSubmit} maxWidth="4xl" mx="auto" pb={8}>
{/* Header with icon */}
<Box mb={8}>
<Stack direction="row" align="center" gap={3} mb={3}>
<Box display="flex" h="11" w="11" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/20" border borderColor="border-primary-blue/20">
<Icon icon={Sparkles} size={5} color="text-primary-blue" />
</Box>
<Box>
<Heading level={1} fontSize={{ base: '2xl', sm: '3xl' }}>
Create a new league
</Heading>
<Text size="sm" color="text-gray-500" block>
We&apos;ll also set up your first season in {steps.length} easy steps.
</Text>
<Text size="xs" color="text-gray-500" block mt={1}>
A league is your long-term brand. Each season is a block of races you can run again and again.
</Text>
</Box>
</Stack>
</Box>
{/* Desktop Progress Bar */}
<Box display={{ base: 'none', md: 'block' }} mb={8}>
<Box position="relative">
{/* Background track */}
<Box position="absolute" top="5" left="6" right="6" h="0.5" bg="bg-charcoal-outline" rounded="full" />
{/* Progress fill */}
<Box
position="absolute"
top="5"
left="6"
h="0.5"
bg="bg-gradient-to-r from-primary-blue to-neon-aqua"
rounded="full"
transition
width={`calc(${((step - 1) / (steps.length - 1)) * 100}% - 48px)`}
/>
<Box position="relative" display="flex" justifyContent="between">
{steps.map((wizardStep) => {
const isCompleted = wizardStep.id < step;
const isCurrent = wizardStep.id === step;
const isAccessible = wizardStep.id <= highestCompletedStep;
const StepIcon = wizardStep.icon;
return (
<Box
as="button"
key={wizardStep.id}
type="button"
onClick={() => goToStep(wizardStep.id)}
disabled={!isAccessible}
display="flex"
flexDirection="col"
alignItems="center"
bg="bg-transparent"
borderStyle="none"
cursor={isAccessible ? 'pointer' : 'not-allowed'}
opacity={!isAccessible ? 0.6 : 1}
>
<Box
position="relative"
zIndex={10}
display="flex"
h="10"
w="10"
alignItems="center"
justifyContent="center"
rounded="full"
transition
bg={isCurrent || isCompleted ? 'bg-primary-blue' : 'bg-iron-gray'}
color={isCurrent || isCompleted ? 'text-white' : 'text-gray-400'}
border={!isCurrent && !isCompleted}
borderColor="border-charcoal-outline"
shadow={isCurrent ? '0_0_24px_rgba(25,140,255,0.5)' : undefined}
transform={isCurrent ? 'scale-110' : isCompleted ? 'hover:scale-105' : undefined}
>
{isCompleted ? (
<Icon icon={Check} size={4} strokeWidth={3} />
) : (
<Icon icon={StepIcon} size={4} />
)}
</Box>
<Box mt={2} textAlign="center">
<Text
size="xs"
weight="medium"
transition
color={isCurrent ? 'text-white' : isCompleted ? 'text-primary-blue' : isAccessible ? 'text-gray-400' : 'text-gray-500'}
>
{wizardStep.label}
</Text>
</Box>
</Box>
);
})}
</Box>
</Box>
</Box>
{/* Mobile Progress */}
<Box display={{ base: 'block', md: 'none' }} mb={6}>
<Box display="flex" alignItems="center" justifyContent="between" mb={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={CurrentStepIcon} size={4} color="text-primary-blue" />
<Text size="sm" weight="medium" color="text-white">{currentStepData?.label}</Text>
</Stack>
<Text size="xs" color="text-gray-500">
{step}/{steps.length}
</Text>
</Box>
<Box h="1.5" bg="bg-charcoal-outline" rounded="full" overflow="hidden">
<Box
h="full"
bg="bg-gradient-to-r from-primary-blue to-neon-aqua"
rounded="full"
transition
height="full"
width={`${(step / steps.length) * 100}%`}
/>
</Box>
{/* Step dots */}
<Box display="flex" justifyContent="between" mt={2} px={0.5}>
{steps.map((s) => (
<Box
key={s.id}
h="1.5"
rounded="full"
transition
width={s.id === step ? '4' : '1.5'}
bg={s.id === step ? 'bg-primary-blue' : s.id < step ? 'bg-primary-blue/60' : 'bg-charcoal-outline'}
/>
))}
</Box>
</Box>
{/* Main Card */}
<Card position="relative" overflow="hidden">
{/* Top gradient accent */}
<Box position="absolute" top="0" left="0" right="0" h="1" bg="bg-gradient-to-r from-transparent via-primary-blue to-transparent" />
{/* Step header */}
<Box display="flex" alignItems="start" gap={4} mb={6}>
<Box display="flex" h="12" w="12" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/10" flexShrink={0} transition>
<Icon icon={CurrentStepIcon} size={6} color="text-primary-blue" />
</Box>
<Box flexGrow={1} minWidth="0">
<Heading level={2} fontSize={{ base: 'xl', md: '2xl' }} color="text-white">
<Stack direction="row" align="center" gap={2} flexWrap="wrap">
<Text>{getStepTitle(step)}</Text>
<Text size="xs" weight="medium" px={2} py={0.5} rounded="full" border borderColor="border-charcoal-outline" bg="bg-iron-gray/60" color="text-gray-300">
{getStepContextLabel(step)}
</Text>
</Stack>
</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
{getStepSubtitle(step)}
</Text>
</Box>
<Box display={{ base: 'none', sm: 'flex' }} alignItems="center" gap={1.5} px={3} py={1.5} rounded="full" bg="bg-deep-graphite" border borderColor="border-charcoal-outline">
<Text size="xs" color="text-gray-500">Step</Text>
<Text size="sm" weight="semibold" color="text-white">{step}</Text>
<Text size="xs" color="text-gray-500">/ {steps.length}</Text>
</Box>
</Box>
{/* Divider */}
<Box h="px" bg="bg-gradient-to-r from-transparent via-charcoal-outline to-transparent" mb={6} />
{/* Step content with min-height for consistency */}
<Box minHeight="320px">
{step === 1 && (
<Box animate="fade-in" gap={8} display="flex" flexDirection="col">
<LeagueBasicsSection
form={form}
onChange={setForm}
errors={errors.basics ?? {}}
/>
<Box rounded="xl" border borderColor="border-charcoal-outline" bg="bg-iron-gray/40" p={4}>
<Box display="flex" alignItems="center" justifyContent="between" gap={2} mb={2}>
<Box>
<Text size="xs" weight="semibold" color="text-gray-300" uppercase letterSpacing="wide">
First season
</Text>
<Text size="xs" color="text-gray-500" block>
Name the first season that will run in this league.
</Text>
</Box>
</Box>
<Box mt={2} display="flex" flexDirection="col" gap={2}>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block>
Season name
</Text>
<Input
value={form.seasonName ?? ''}
onChange={(e) =>
setForm((prev) => ({
...prev,
seasonName: e.target.value,
}))
}
placeholder="e.g., Season 1 (2025)"
/>
<Text size="xs" color="text-gray-500" block>
Seasons are the individual competitive runs inside your league. You can run Season 2, Season 3, or parallel seasons later.
</Text>
</Box>
</Box>
</Box>
)}
{step === 2 && (
<Box animate="fade-in">
<LeagueVisibilitySection
form={form}
onChange={setForm}
errors={
errors.basics?.visibility
? { visibility: errors.basics.visibility }
: {}
}
/>
</Box>
)}
{step === 3 && (
<Box animate="fade-in" display="flex" flexDirection="col" gap={4}>
<Box mb={2}>
<Text size="xs" color="text-gray-500" block>
Applies to: First season of this league.
</Text>
<Text size="xs" color="text-gray-500" block>
These settings only affect this season. Future seasons can use different formats.
</Text>
</Box>
<LeagueStructureSection
form={form}
onChange={setForm}
readOnly={false}
/>
</Box>
)}
{step === 4 && (
<Box animate="fade-in" display="flex" flexDirection="col" gap={4}>
<Box mb={2}>
<Text size="xs" color="text-gray-500" block>
Applies to: First season of this league.
</Text>
<Text size="xs" color="text-gray-500" block>
These settings only affect this season. Future seasons can use different formats.
</Text>
</Box>
<LeagueTimingsSection
form={form}
onChange={setForm}
errors={errors.timings ?? {}}
/>
</Box>
)}
{step === 5 && (
<Box animate="fade-in" display="flex" flexDirection="col" gap={8}>
<Box mb={2}>
<Text size="xs" color="text-gray-500" block>
Applies to: First season of this league.
</Text>
<Text size="xs" color="text-gray-500" block>
These settings only affect this season. Future seasons can use different formats.
</Text>
</Box>
{/* Scoring Pattern Selection */}
<ScoringPatternSection
scoring={form.scoring || {}}
presets={presets}
readOnly={presetsLoading}
patternError={errors.scoring?.patternId ?? ''}
onChangePatternId={handleScoringPresetChange}
onToggleCustomScoring={() =>
setForm((prev) => ({
...prev,
scoring: {
...prev.scoring,
customScoringEnabled: !(prev.scoring?.customScoringEnabled),
},
}))
}
/>
{/* Divider */}
<Box h="px" bg="bg-gradient-to-r from-transparent via-charcoal-outline to-transparent" />
{/* Championships & Drop Rules side by side on larger screens */}
<Box display="grid" gridCols={{ base: 1, lg: 2 }} gap={6}>
<ChampionshipsSection form={form} onChange={setForm} readOnly={presetsLoading} />
<LeagueDropSection form={form} onChange={setForm} readOnly={false} />
</Box>
{errors.submit && (
<Box display="flex" alignItems="start" gap={3} rounded="lg" bg="bg-warning-amber/10" p={4} border borderColor="border-warning-amber/20">
<Icon icon={AlertCircle} size={5} color="text-warning-amber" flexShrink={0} mt={0.5} />
<Text size="sm" color="text-warning-amber">{errors.submit}</Text>
</Box>
)}
</Box>
)}
{step === 6 && (
<Box animate="fade-in" display="flex" flexDirection="col" gap={4}>
<Box mb={2}>
<Text size="xs" color="text-gray-500" block>
Applies to: First season of this league.
</Text>
<Text size="xs" color="text-gray-500" block>
These settings only affect this season. Future seasons can use different formats.
</Text>
</Box>
<LeagueStewardingSection
form={form}
onChange={setForm}
readOnly={false}
/>
</Box>
)}
{step === 7 && (
<Box animate="fade-in" display="flex" flexDirection="col" gap={6}>
<LeagueReviewSummary form={form} presets={presets} />
{errors.submit && (
<Box display="flex" alignItems="start" gap={3} rounded="lg" bg="bg-warning-amber/10" p={4} border borderColor="border-warning-amber/20">
<Icon icon={AlertCircle} size={5} color="text-warning-amber" flexShrink={0} mt={0.5} />
<Text size="sm" color="text-warning-amber">{errors.submit}</Text>
</Box>
)}
</Box>
)}
</Box>
</Card>
{/* Navigation */}
<Box display="flex" alignItems="center" justifyContent="between" mt={6}>
<Button
type="button"
variant="secondary"
disabled={step === 1 || loading}
onClick={goToPreviousStep}
icon={<Icon icon={ChevronLeft} size={4} />}
>
<Text display={{ base: 'none', md: 'inline-block' }}>Back</Text>
</Button>
<Box display="flex" alignItems="center" gap={3}>
{/* Mobile step dots */}
<Box display={{ base: 'flex', sm: 'none' }} alignItems="center" gap={1}>
{steps.map((s) => (
<Box
key={s.id}
h="1.5"
rounded="full"
transition
width={s.id === step ? '3' : '1.5'}
bg={s.id === step ? 'bg-primary-blue' : s.id < step ? 'bg-primary-blue/50' : 'bg-charcoal-outline'}
/>
))}
</Box>
{step < 7 ? (
<Button
type="button"
variant="primary"
disabled={loading}
onClick={goToNextStep}
icon={<Icon icon={ChevronRight} size={4} />}
flexDirection="row-reverse"
>
<Text>Continue</Text>
</Button>
) : (
<Button
type="submit"
variant="primary"
disabled={loading}
minWidth="150px"
justifyContent="center"
icon={loading ? <Icon icon={Loader2} size={4} animate="spin" /> : <Icon icon={Sparkles} size={4} />}
>
{loading ? (
<Text>Creating</Text>
) : (
<Text>Create League</Text>
)}
</Button>
)}
</Box>
</Box>
{/* Helper text */}
<Text size="xs" color="text-gray-500" align="center" block mt={4}>
This will create your league and its first season. You can edit both later.
</Text>
</Box>
<CreateLeagueWizardTemplate
viewData={{}}
step={step}
steps={steps}
form={form}
errors={errors}
loading={loading}
presetsLoading={presetsLoading}
presets={presets}
highestCompletedStep={highestCompletedStep}
onGoToStep={goToStep}
onFormChange={setForm}
onSubmit={handleSubmit}
onNextStep={goToNextStep}
onPreviousStep={goToPreviousStep}
onScoringPresetChange={handleScoringPresetChange}
onToggleCustomScoring={() =>
setForm((prev) => ({
...prev,
scoring: {
...prev.scoring,
customScoringEnabled: !(prev.scoring?.customScoringEnabled),
},
}))
}
getStepTitle={getStepTitle}
getStepSubtitle={getStepSubtitle}
getStepContextLabel={getStepContextLabel}
/>
);
}

View File

@@ -3,11 +3,10 @@
import type { ProfileTab } from '@/components/profile/ProfileTabs';
import type { DriverProfileViewData } from '@/lib/types/view-data/DriverProfileViewData';
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { ErrorTemplate, EmptyTemplate } from '@/templates/shared/StatusTemplates';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface DriverProfilePageClientProps {
viewData: DriverProfileViewData | null;
@@ -18,13 +17,6 @@ interface DriverProfilePageClientProps {
};
}
/**
* DriverProfilePageClient
*
* Client component that:
* 1. Handles UI state (tabs, friend requests)
* 2. Passes ViewData directly to Template
*/
export function DriverProfilePageClient({ viewData, error, empty }: DriverProfilePageClientProps) {
const router = useRouter();
@@ -44,24 +36,22 @@ export function DriverProfilePageClient({ viewData, error, empty }: DriverProfil
// Handle error/empty states
if (error) {
return (
<Container size="lg" py={12}>
<Stack align="center" gap={4}>
<Text color="text-red-400">Error loading driver profile</Text>
<Text color="text-gray-400">Please try again later</Text>
</Stack>
</Container>
<ErrorTemplate
viewData={{}}
message="Error loading driver profile"
description="Please try again later"
/>
);
}
if (!viewData || !viewData.currentDriver) {
if (empty) {
return (
<Container size="lg" py={12}>
<Stack align="center" gap={2}>
<Text size="xl" weight="semibold" color="text-white">{empty.title}</Text>
<Text color="text-gray-400">{empty.description}</Text>
</Stack>
</Container>
<EmptyTemplate
viewData={{}}
title={empty.title}
description={empty.description}
/>
);
}
return null;
@@ -78,4 +68,4 @@ export function DriverProfilePageClient({ viewData, error, empty }: DriverProfil
onTabChange={setActiveTab}
/>
);
}
}

View File

@@ -5,12 +5,9 @@ import { useRouter } from 'next/navigation';
import { DriverRankingsTemplate } from '@/templates/DriverRankingsTemplate';
import { routes } from '@/lib/routing/RouteConfig';
import type { DriverRankingsViewData } from '@/lib/view-data/DriverRankingsViewData';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface DriverRankingsPageClientProps {
viewData: DriverRankingsViewData;
}
export function DriverRankingsPageClient({ viewData }: DriverRankingsPageClientProps) {
export function DriverRankingsPageClient({ viewData }: ClientWrapperProps<DriverRankingsViewData>) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');

View File

@@ -2,13 +2,11 @@
import type { DriversViewData } from '@/lib/types/view-data/DriversViewData';
import { DriversTemplate } from '@/templates/DriversTemplate';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { ErrorTemplate, EmptyTemplate } from '@/templates/shared/StatusTemplates';
import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
import { routes } from '@/lib/routing/RouteConfig';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface DriversPageClientProps {
viewData: DriversViewData | null;
@@ -19,14 +17,6 @@ interface DriversPageClientProps {
};
}
/**
* DriversPageClient
*
* Client component that:
* 1. Manages search state
* 2. Filters drivers based on search
* 3. Passes ViewData to Template
*/
export function DriversPageClient({ viewData, error, empty }: DriversPageClientProps) {
const [searchQuery, setSearchQuery] = useState('');
const router = useRouter();
@@ -53,24 +43,22 @@ export function DriversPageClient({ viewData, error, empty }: DriversPageClientP
// Handle error/empty states
if (error) {
return (
<Container size="lg" py={12}>
<Stack align="center" gap={4}>
<Text color="text-red-400">Error loading drivers</Text>
<Text color="text-gray-400">Please try again later</Text>
</Stack>
</Container>
<ErrorTemplate
viewData={{}}
message="Error loading drivers"
description="Please try again later"
/>
);
}
if (!viewData || viewData.drivers.length === 0) {
if (empty) {
return (
<Container size="lg" py={12}>
<Stack align="center" gap={2}>
<Text size="xl" weight="semibold" color="text-white">{empty.title}</Text>
<Text color="text-gray-400">{empty.description}</Text>
</Stack>
</Container>
<EmptyTemplate
viewData={{}}
title={empty.title}
description={empty.description}
/>
);
}
return null;
@@ -86,4 +74,4 @@ export function DriversPageClient({ viewData, error, empty }: DriversPageClientP
onViewLeaderboard={handleViewLeaderboard}
/>
);
}
}

View File

@@ -13,12 +13,9 @@ import { ForgotPasswordMutation } from '@/lib/mutations/auth/ForgotPasswordMutat
import { ForgotPasswordViewModelBuilder } from '@/lib/builders/view-models/ForgotPasswordViewModelBuilder';
import { ForgotPasswordViewModel } from '@/lib/view-models/auth/ForgotPasswordViewModel';
import { ForgotPasswordFormValidation } from '@/lib/utilities/authValidation';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface ForgotPasswordClientProps {
viewData: ForgotPasswordViewData;
}
export function ForgotPasswordClient({ viewData }: ForgotPasswordClientProps) {
export function ForgotPasswordClient({ viewData }: ClientWrapperProps<ForgotPasswordViewData>) {
// Build ViewModel from ViewData
const [viewModel, setViewModel] = useState<ForgotPasswordViewModel>(() =>
ForgotPasswordViewModelBuilder.build(viewData)
@@ -127,4 +124,4 @@ export function ForgotPasswordClient({ viewData }: ForgotPasswordClientProps) {
}}
/>
);
}
}

View File

@@ -5,12 +5,9 @@ import { useRouter } from 'next/navigation';
import { LeaderboardsTemplate } from '@/templates/LeaderboardsTemplate';
import { routes } from '@/lib/routing/RouteConfig';
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface LeaderboardsPageClientProps {
viewData: LeaderboardsViewData;
}
export function LeaderboardsPageClient({ viewData }: LeaderboardsPageClientProps) {
export function LeaderboardsPageClient({ viewData }: ClientWrapperProps<LeaderboardsViewData>) {
const router = useRouter();
const handleDriverClick = (id: string) => {

View File

@@ -7,8 +7,8 @@ import {
unpublishScheduleAction,
updateRaceAction
} from '@/app/actions/leagueScheduleActions';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { ConfirmDialog } from '@/ui/ConfirmDialog';
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
import { SharedConfirmDialog, SharedStack, SharedCard, SharedBox, SharedText, SharedHeading } from '@/components/shared/UIComponents';
import {
useLeagueAdminSchedule,
useLeagueAdminStatus,
@@ -17,11 +17,6 @@ import {
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { RaceScheduleCommandModel } from '@/lib/command-models/leagues/RaceScheduleCommandModel';
import { LeagueAdminScheduleTemplate } from '@/templates/LeagueAdminScheduleTemplate';
import { Box } from '@/ui/Box';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
@@ -177,74 +172,67 @@ export function LeagueAdminSchedulePageClient() {
// Render admin access required if not admin
if (!isLoading && !isAdmin) {
return (
<Stack gap={6}>
<Card>
<Box p={6} textAlign="center">
<SharedStack gap={6}>
<SharedCard>
<SharedBox p={6} textAlign="center">
<Heading level={3}>Admin Access Required</Heading>
<Box mt={2}>
<Text size="sm" color="text-gray-400">Only league admins can manage the schedule.</Text>
</Box>
</Box>
</Card>
</Stack>
<SharedBox mt={2}>
<SharedText size="sm" color="text-gray-400">Only league admins can manage the schedule.</SharedText>
</SharedBox>
</SharedBox>
</SharedCard>
</SharedStack>
);
}
// Template component that wraps the actual template with all props
const TemplateWrapper = ({ data }: { data: typeof templateData }) => {
if (!data) return null;
return (
<>
<LeagueAdminScheduleTemplate
viewData={data}
onSeasonChange={handleSeasonChange}
onPublishToggle={handlePublishToggle}
onAddOrSave={handleAddOrSave}
onEdit={handleEdit}
onDelete={handleDelete}
onCancelEdit={handleCancelEdit}
track={form.track}
car={form.car}
scheduledAtIso={form.scheduledAtIso}
editingRaceId={editingRaceId}
isPublishing={isPublishing}
isSaving={isSaving}
isDeleting={deletingRaceId}
error={error}
setTrack={(val) => {
form.track = val;
setForm(new RaceScheduleCommandModel(form.toCommand()));
}}
setCar={(val) => {
form.car = val;
setForm(new RaceScheduleCommandModel(form.toCommand()));
}}
setScheduledAtIso={(val) => {
form.scheduledAtIso = val;
setForm(new RaceScheduleCommandModel(form.toCommand()));
}}
/>
<ConfirmDialog
isOpen={!!raceToDelete}
onClose={() => setRaceToDelete(null)}
onConfirm={confirmDelete}
title="Delete Race"
description="Are you sure you want to delete this race? This will remove it from the schedule and cannot be undone."
confirmLabel="Delete Race"
variant="danger"
isLoading={!!deletingRaceId}
/>
</>
);
};
return (
<PageWrapper
<StatefulPageWrapper
data={templateData}
isLoading={isLoading}
error={null}
Template={TemplateWrapper}
Template={({ data }) => (
<>
<LeagueAdminScheduleTemplate
viewData={data}
onSeasonChange={handleSeasonChange}
onPublishToggle={handlePublishToggle}
onAddOrSave={handleAddOrSave}
onEdit={handleEdit}
onDelete={handleDelete}
onCancelEdit={handleCancelEdit}
track={form.track}
car={form.car}
scheduledAtIso={form.scheduledAtIso}
editingRaceId={editingRaceId}
isPublishing={isPublishing}
isSaving={isSaving}
isDeleting={deletingRaceId}
error={error}
setTrack={(val) => {
form.track = val;
setForm(new RaceScheduleCommandModel(form.toCommand()));
}}
setCar={(val) => {
form.car = val;
setForm(new RaceScheduleCommandModel(form.toCommand()));
}}
setScheduledAtIso={(val) => {
form.scheduledAtIso = val;
setForm(new RaceScheduleCommandModel(form.toCommand()));
}}
/>
<SharedConfirmDialog
isOpen={!!raceToDelete}
onClose={() => setRaceToDelete(null)}
onConfirm={confirmDelete}
title="Delete Race"
description="Are you sure you want to delete this race? This will remove it from the schedule and cannot be undone."
confirmLabel="Delete Race"
variant="danger"
isLoading={!!deletingRaceId}
/>
</>
)}
loading={{ variant: 'full-screen', message: 'Loading schedule admin...' }}
empty={{
title: 'No schedule data available',

View File

@@ -4,12 +4,9 @@ import React, { useState } from 'react';
import { LeagueRulebookTemplate } from '@/templates/LeagueRulebookTemplate';
import { type RulebookSection } from '@/components/leagues/RulebookTabs';
import type { LeagueRulebookViewData } from '@/lib/view-data/LeagueRulebookViewData';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface LeagueRulebookPageClientProps {
viewData: LeagueRulebookViewData;
}
export function LeagueRulebookPageClient({ viewData }: LeagueRulebookPageClientProps) {
export function LeagueRulebookPageClient({ viewData }: ClientWrapperProps<LeagueRulebookViewData>) {
const [activeSection, setActiveSection] = useState<RulebookSection>('scoring');
return (

View File

@@ -1,26 +1,16 @@
'use client';
import { WalletSummaryPanel } from '@/components/leagues/WalletSummaryPanel';
import type { LeagueWalletViewData } from '@/lib/view-data/leagues/LeagueWalletViewData';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Icon as UIIcon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import {
Download
} from 'lucide-react';
import { LeagueWalletTemplate } from '@/templates/LeagueWalletTemplate';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface WalletTemplateProps {
viewData: LeagueWalletViewData;
interface LeagueWalletPageClientProps extends ClientWrapperProps<LeagueWalletViewData> {
onWithdraw?: (amount: number) => void;
onExport?: () => void;
mutationLoading?: boolean;
}
export function LeagueWalletPageClient({ viewData, onExport }: WalletTemplateProps) {
export function LeagueWalletPageClient({ viewData, onExport }: LeagueWalletPageClientProps) {
// Map transactions to the format expected by WalletSummaryPanel
const transactions = viewData.transactions.map(t => ({
id: t.id,
@@ -31,36 +21,10 @@ export function LeagueWalletPageClient({ viewData, onExport }: WalletTemplatePro
}));
return (
<Container size="lg" py={8}>
{/* Header */}
<Box display="flex" alignItems="center" justifyContent="between" mb={8}>
<Box>
<Heading level={1}>League Wallet</Heading>
<Text color="text-gray-400">Manage your league&apos;s finances and payouts</Text>
</Box>
<Button variant="secondary" onClick={onExport}>
<Stack direction="row" align="center" gap={2}>
<UIIcon icon={Download} size={4} />
<Text>Export</Text>
</Stack>
</Button>
</Box>
<WalletSummaryPanel
balance={viewData.balance}
currency="USD"
transactions={transactions}
onDeposit={() => {}} // Not implemented for leagues yet
onWithdraw={() => {}} // Not implemented for leagues yet
/>
{/* Alpha Notice */}
<Box mt={6} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30" p={4}>
<Text size="xs" color="text-gray-400">
<Text weight="bold" color="text-warning-amber">Alpha Note:</Text> Wallet management is demonstration-only.
Real payment processing and bank integrations will be available when the payment system is fully implemented.
</Text>
</Box>
</Container>
<LeagueWalletTemplate
viewData={viewData}
onExport={onExport}
transactions={transactions}
/>
);
}

View File

@@ -1,65 +1,23 @@
'use client';
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
import { routes } from '@/lib/routing/RouteConfig';
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
import { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';
import { Input } from '@/ui/Input';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import {
Award,
Clock,
Flag,
Flame,
Globe,
Plus,
Search,
Sparkles,
Target,
Timer,
Trophy,
Users,
type LucideIcon,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import React, { useState } from 'react';
// ============================================================================
// TYPES
// ============================================================================
type CategoryId =
| 'all'
| 'driver'
| 'team'
| 'nations'
| 'trophy'
| 'new'
| 'popular'
| 'openSlots'
| 'endurance'
| 'sprint';
interface Category {
id: CategoryId;
label: string;
icon: LucideIcon;
description: string;
filter: (league: LeaguesViewData['leagues'][number]) => boolean;
color?: string;
}
interface LeaguesTemplateProps {
viewData: LeaguesViewData;
}
// ============================================================================
// CATEGORIES
// ============================================================================
import { LeaguesTemplate, Category, CategoryId } from '@/templates/LeaguesTemplate';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
const CATEGORIES: Category[] = [
{
@@ -157,7 +115,7 @@ const CATEGORIES: Category[] = [
},
];
export function LeaguesPageClient({ viewData }: LeaguesTemplateProps) {
export function LeaguesPageClient({ viewData }: ClientWrapperProps<LeaguesViewData>) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
const [activeCategory, setActiveCategory] = useState<CategoryId>('all');
@@ -174,107 +132,17 @@ export function LeaguesPageClient({ viewData }: LeaguesTemplateProps) {
});
return (
<Box minHeight="screen" bg="zinc-950" color="text-zinc-200">
<Box maxWidth="7xl" mx="auto" px={{ base: 4, sm: 6, lg: 8 }} py={12}>
{/* Hero */}
<Box as="header" display="flex" flexDirection={{ base: 'col', md: 'row' }} alignItems={{ base: 'start', md: 'end' }} justifyContent="between" gap={8} mb={16}>
<Stack gap={4}>
<Box display="flex" alignItems="center" gap={3} color="text-blue-500">
<Trophy size={24} />
<Text fontSize="xs" weight="bold" uppercase letterSpacing="widest">Competition Hub</Text>
</Box>
<Heading level={1} fontSize="5xl" weight="bold" color="text-white">
Find Your <Text as="span" color="text-blue-500">Grid</Text>
</Heading>
<Text color="text-zinc-400" maxWidth="md" leading="relaxed">
From casual sprints to epic endurance battles discover the perfect league for your racing style.
</Text>
</Stack>
<Box display="flex" alignItems="center" gap={4}>
<Box display="flex" flexDirection="col" alignItems="end">
<Text fontSize="2xl" weight="bold" color="text-white" font="mono">{viewData.leagues.length}</Text>
<Text weight="bold" color="text-zinc-500" uppercase letterSpacing="widest" fontSize="10px">Active Leagues</Text>
</Box>
<Box w="px" h="8" bg="zinc-800" />
<Button
onClick={() => router.push(routes.league.create)}
variant="primary"
size="lg"
>
<Stack direction="row" align="center" gap={2}>
<Plus size={16} />
Create League
</Stack>
</Button>
</Box>
</Box>
{/* Search & Filters */}
<Box as="section" display="flex" flexDirection="col" gap={8} mb={12}>
<Input
type="text"
placeholder="Search leagues by name, description, or game..."
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
icon={<Search size={20} />}
/>
<Box as="nav" display="flex" flexWrap="wrap" gap={2}>
{CATEGORIES.map((category) => {
const isActive = activeCategory === category.id;
const CategoryIcon = category.icon;
return (
<Button
key={category.id}
onClick={() => setActiveCategory(category.id)}
variant={isActive ? 'primary' : 'secondary'}
size="sm"
>
<Stack direction="row" align="center" gap={2}>
<Box
color={!isActive && category.color ? category.color : undefined}
>
<CategoryIcon size={14} />
</Box>
<Text>{category.label}</Text>
</Stack>
</Button>
);
})}
</Box>
</Box>
{/* Grid */}
<Box as="main">
{filteredLeagues.length > 0 ? (
<Box display="grid" responsiveGridCols={{ base: 1, md: 2, lg: 3 }} gap={6}>
{filteredLeagues.map((league) => (
<LeagueCard
key={league.id}
league={league as unknown as LeagueSummaryViewModel}
onClick={() => router.push(routes.league.detail(league.id))}
/>
))}
</Box>
) : (
<Box display="flex" flexDirection="col" alignItems="center" justifyContent="center" py={24} border borderStyle="dashed" borderColor="zinc-800" bg="zinc-900/20">
<Box color="text-zinc-800" mb={4}>
<Search size={48} />
</Box>
<Heading level={3} fontSize="xl" weight="bold" color="text-zinc-500">No Leagues Found</Heading>
<Text color="text-zinc-600" size="sm" mt={2}>Try adjusting your search or filters</Text>
<Button
variant="ghost"
mt={6}
onClick={() => { setSearchQuery(''); setActiveCategory('all'); }}
>
Clear All Filters
</Button>
</Box>
)}
</Box>
</Box>
</Box>
<LeaguesTemplate
viewData={viewData}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
activeCategory={activeCategory}
onCategoryChange={setActiveCategory}
filteredLeagues={filteredLeagues}
categories={CATEGORIES}
onCreateLeague={() => router.push(routes.league.create)}
onLeagueClick={(id) => router.push(routes.league.detail(id))}
onClearFilters={() => { setSearchQuery(''); setActiveCategory('all'); }}
/>
);
}

View File

@@ -8,7 +8,6 @@
'use client';
import { useAuth } from '@/components/auth/AuthContext';
import { AuthLoading } from '@/components/auth/AuthLoading';
import { LoginFlowController, LoginState } from '@/lib/auth/LoginFlowController';
import { LoginViewData } from '@/lib/builders/view-data/types/LoginViewData';
import { LoginViewModelBuilder } from '@/lib/builders/view-models/LoginViewModelBuilder';
@@ -16,14 +15,12 @@ import { LoginMutation } from '@/lib/mutations/auth/LoginMutation';
import { validateLoginForm, type LoginFormValues } from '@/lib/utils/validation';
import { LoginViewModel } from '@/lib/view-models/auth/LoginViewModel';
import { LoginTemplate } from '@/templates/auth/LoginTemplate';
import { LoginLoadingTemplate } from '@/templates/auth/LoginLoadingTemplate';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface LoginClientProps {
viewData: LoginViewData;
}
export function LoginClient({ viewData }: LoginClientProps) {
export function LoginClient({ viewData }: ClientWrapperProps<LoginViewData>) {
const router = useRouter();
const searchParams = useSearchParams();
const { refreshSession, session } = useAuth();
@@ -185,7 +182,7 @@ export function LoginClient({ viewData }: LoginClientProps) {
// If user is authenticated with permissions, show loading
if (state === LoginState.AUTHENTICATED_WITH_PERMISSIONS) {
return <AuthLoading />;
return <LoginLoadingTemplate viewData={{}} />;
}
// If user has insufficient permissions, show permission error
@@ -264,4 +261,4 @@ export function LoginClient({ viewData }: LoginClientProps) {
}}
/>
);
}
}

View File

@@ -2,23 +2,13 @@
import React from 'react';
import { MediaTemplate } from '@/templates/MediaTemplate';
import { MediaAsset } from '@/components/media/MediaGallery';
import { MediaViewData } from '@/lib/view-data/MediaViewData';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
export interface MediaPageClientProps {
initialAssets: MediaAsset[];
categories: { label: string; value: string }[];
}
export function MediaPageClient({
initialAssets,
categories,
}: MediaPageClientProps) {
export function MediaPageClient({ viewData }: ClientWrapperProps<MediaViewData>) {
return (
<MediaTemplate
assets={initialAssets}
categories={categories}
title="Media Library"
description="Manage and view all racing assets, telemetry captures, and brand identities."
viewData={viewData}
/>
);
}

View File

@@ -4,6 +4,7 @@ import React from 'react';
import { useRouter } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import { NotFoundTemplate, type NotFoundViewData } from '@/templates/NotFoundTemplate';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
/**
* NotFoundPageClient
@@ -11,14 +12,14 @@ import { NotFoundTemplate, type NotFoundViewData } from '@/templates/NotFoundTem
* Client-side entry point for the 404 page.
* Manages navigation logic and wires it to the template.
*/
export function NotFoundPageClient() {
export function NotFoundPageClient({ viewData: initialViewData }: Partial<ClientWrapperProps<NotFoundViewData>>) {
const router = useRouter();
const handleHomeClick = () => {
router.push(routes.public.home);
};
const viewData: NotFoundViewData = {
const viewData: NotFoundViewData = initialViewData ?? {
errorCode: 'Error 404',
title: 'OFF TRACK',
message: 'The requested sector does not exist. You have been returned to the pits.',

View File

@@ -8,6 +8,8 @@ import { useAuth } from '@/components/auth/AuthContext';
import { useState } from 'react';
import { PersonalInfo } from '@/components/onboarding/PersonalInfoStep';
import { AvatarInfo } from '@/components/onboarding/AvatarStep';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
type OnboardingStep = 1 | 2;
@@ -22,7 +24,7 @@ interface FormErrors {
submit?: string;
}
export function OnboardingWizardClient() {
export function OnboardingWizardClient({ viewData: initialViewData }: Partial<ClientWrapperProps<ViewData>>) {
const { session } = useAuth();
const [isProcessing, setIsProcessing] = useState(false);
const [step, setStep] = useState<OnboardingStep>(1);

View File

@@ -1,19 +1,11 @@
'use client';
import { UploadDropzone } from '@/ui/UploadDropzone';
import { routes } from '@/lib/routing/RouteConfig';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { MediaMetaPanel, mapMediaMetadata } from '@/ui/MediaMetaPanel';
import { MediaPreviewCard } from '@/ui/MediaPreviewCard';
import { Text } from '@/ui/Text';
import Link from 'next/link';
import { useState } from 'react';
import { ProfileLiveryUploadTemplate } from '@/templates/ProfileLiveryUploadTemplate';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
export function ProfileLiveryUploadPageClient() {
export function ProfileLiveryUploadPageClient({ viewData: initialViewData }: Partial<ClientWrapperProps<ViewData>>) {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
@@ -40,70 +32,13 @@ export function ProfileLiveryUploadPageClient() {
};
return (
<Container size="md">
<Box mb={6}>
<Heading level={1}>Upload livery</Heading>
<Text color="text-gray-500">
Upload your custom car livery. Supported formats: .png, .jpg, .tga
</Text>
</Box>
<Box display="grid" responsiveGridCols={{ base: 1, md: 2 }} gap={6}>
<Box>
<Card>
<UploadDropzone
onFilesSelected={handleFilesSelected}
accept=".png,.jpg,.jpeg,.tga"
maxSize={10 * 1024 * 1024} // 10MB
isLoading={isUploading}
/>
<Box mt={6} display="flex" justifyContent="end" gap={3}>
<Link href={routes.protected.profileLiveries}>
<Button variant="ghost">Cancel</Button>
</Link>
<Button
variant="primary"
disabled={!selectedFile || isUploading}
onClick={handleUpload}
isLoading={isUploading}
>
Upload Livery
</Button>
</Box>
</Card>
</Box>
<Box>
{previewUrl ? (
<Box display="flex" flexDirection="col" gap={6}>
<MediaPreviewCard
type="image"
src={previewUrl}
alt={selectedFile?.name || 'Livery preview'}
title={selectedFile?.name}
subtitle="Preview"
aspectRatio="16/9"
/>
<MediaMetaPanel
items={mapMediaMetadata({
filename: selectedFile?.name,
size: selectedFile?.size,
contentType: selectedFile?.type || 'image/tga',
createdAt: new Date(),
})}
/>
</Box>
) : (
<Card center p={12}>
<Text color="text-gray-500" align="center">
Select a file to see preview and details
</Text>
</Card>
)}
</Box>
</Box>
</Container>
<ProfileLiveryUploadTemplate
viewData={{}}
selectedFile={selectedFile}
previewUrl={previewUrl}
isUploading={isUploading}
onFilesSelected={handleFilesSelected}
onUpload={handleUpload}
/>
);
}

View File

@@ -5,9 +5,9 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { ProfileTemplate } from '@/templates/ProfileTemplate';
import { type ProfileTab } from '@/components/profile/ProfileNavTabs';
import type { ProfileViewData } from '@/lib/view-data/ProfileViewData';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface ProfilePageClientProps {
viewData: ProfileViewData;
interface ProfilePageClientProps extends ClientWrapperProps<ProfileViewData> {
mode: 'profile-exists' | 'needs-profile';
}

View File

@@ -1,16 +1,21 @@
'use client';
import { InlineNotice } from '@/ui/InlineNotice';
import { ProgressLine } from '@/ui/ProgressLine';
import type { Result } from '@/lib/contracts/Result';
import type { ProfileViewData } from '@/lib/view-data/ProfileViewData';
import { ProfileSettingsTemplate } from '@/templates/ProfileSettingsTemplate';
import { Box } from '@/ui/Box';
import {
SharedBox,
SharedStack,
SharedText,
SharedIcon,
SharedProgressLine
} from '@/components/shared/UIComponents';
import { ShieldAlert } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface ProfileSettingsPageClientProps {
viewData: ProfileViewData;
interface ProfileSettingsPageClientProps extends ClientWrapperProps<ProfileViewData> {
onSave: (updates: { bio?: string; country?: string }) => Promise<Result<void, string>>;
}
@@ -41,15 +46,19 @@ export function ProfileSettingsPageClient({ viewData, onSave }: ProfileSettingsP
return (
<>
<ProgressLine isLoading={isSaving} />
<SharedProgressLine isLoading={isSaving} />
{error && (
<Box position="fixed" top={4} right={4} zIndex={50} maxWidth="md">
<InlineNotice
variant="error"
title="Update Failed"
message={error}
/>
</Box>
<SharedBox position="fixed" top={4} right={4} zIndex={50} maxWidth="md">
<SharedBox bg="bg-error-red/10" p={4} rounded="md" border borderColor="border-error-red/20">
<SharedStack direction="row" align="center" gap={3}>
<SharedIcon icon={ShieldAlert} size={5} color="text-error-red" />
<SharedBox>
<SharedText weight="bold" color="text-error-red">Update Failed</SharedText>
<SharedText size="sm" color="text-error-red/80">{error}</SharedText>
</SharedBox>
</SharedStack>
</SharedBox>
</SharedBox>
)}
<ProfileSettingsTemplate
viewData={viewData}

View File

@@ -1,66 +1,33 @@
'use client';
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
import { useInject } from '@/lib/di/hooks/useInject';
import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import {
AlertCircle,
AlertTriangle,
ArrowLeft,
Calendar,
CheckCircle,
ChevronDown,
Clock,
ExternalLink,
Flag,
Gavel,
Grid3x3,
MapPin,
MessageCircle,
Send,
Shield,
ShieldAlert,
TrendingDown,
User,
Video,
XCircle,
type LucideIcon
} from 'lucide-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
// Shared state components
import { LoadingWrapper } from '@/ui/LoadingWrapper';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useLeagueAdminStatus } from "@/hooks/league/useLeagueAdminStatus";
import { useProtestDetail } from "@/hooks/league/useProtestDetail";
import { routes } from '@/lib/routing/RouteConfig';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Icon as UIIcon } from '@/ui/Icon';
import { Link as UILink } from '@/ui/Link';
import { Grid } from '@/ui/Grid';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { ProtestDetailTemplate } from '@/templates/ProtestDetailTemplate';
import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
const GridItem = ({ children, colSpan, lgSpan }: { children: React.ReactNode; colSpan?: number; lgSpan?: number }) => (
<Box gridCols={colSpan} style={{ gridColumn: `span ${colSpan} / span ${colSpan}` }} className={lgSpan ? `lg:col-span-${lgSpan}` : ''}>
{children}
</Box>
);
type PenaltyUiConfig = {
label: string;
description: string;
icon: LucideIcon;
color: string;
defaultValue?: number;
};
const PENALTY_UI: Record<string, PenaltyUiConfig> = {
const PENALTY_UI: Record<string, any> = {
time_penalty: {
label: 'Time Penalty',
description: 'Add seconds to race result',
@@ -105,7 +72,7 @@ const PENALTY_UI: Record<string, PenaltyUiConfig> = {
},
};
export function ProtestDetailPageClient({ initialViewData }: { initialViewData: unknown }) {
export function ProtestDetailPageClient({ viewData: initialViewData }: Partial<ClientWrapperProps<ViewData>>) {
const params = useParams();
const router = useRouter();
const leagueId = params.id as string;
@@ -129,7 +96,6 @@ export function ProtestDetailPageClient({ initialViewData }: { initialViewData:
const { data: detail, isLoading: detailLoading, error, retry } = useProtestDetail(leagueId, protestId, isAdmin || false);
// Use initial data if available
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const protestDetail = (detail || initialViewData) as any;
// Set initial penalty values when data loads
@@ -142,7 +108,6 @@ export function ProtestDetailPageClient({ initialViewData }: { initialViewData:
const penaltyTypes = useMemo(() => {
const referenceItems = protestDetail?.penaltyTypes ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return referenceItems.map((ref: any) => {
const ui = PENALTY_UI[ref.type] ?? {
icon: Gavel,
@@ -158,7 +123,6 @@ export function ProtestDetailPageClient({ initialViewData }: { initialViewData:
}, [protestDetail?.penaltyTypes]);
const selectedPenalty = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return penaltyTypes.find((p: any) => p.type === penaltyType);
}, [penaltyTypes, penaltyType]);
@@ -182,11 +146,7 @@ export function ProtestDetailPageClient({ initialViewData }: { initialViewData:
stewardNotes,
});
const options: {
requiresValue?: boolean;
defaultUpheldReason?: string;
defaultDismissedReason?: string;
} = { requiresValue };
const options: any = { requiresValue };
if (defaultUpheldReason) {
options.defaultUpheldReason = defaultUpheldReason;
@@ -208,7 +168,6 @@ export function ProtestDetailPageClient({ initialViewData }: { initialViewData:
throw new Error(result.getError().message);
}
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const warningRef = protestDetail.penaltyTypes.find((p: any) => p.type === 'warning');
const requiresValue = warningRef?.requiresValue ?? false;
@@ -219,11 +178,7 @@ export function ProtestDetailPageClient({ initialViewData }: { initialViewData:
stewardNotes,
});
const options: {
requiresValue?: boolean;
defaultUpheldReason?: string;
defaultDismissedReason?: string;
} = { requiresValue };
const options: any = { requiresValue };
if (defaultUpheldReason) {
options.defaultUpheldReason = defaultUpheldReason;
@@ -258,7 +213,6 @@ export function ProtestDetailPageClient({ initialViewData }: { initialViewData:
if (!protestDetail || !currentDriverId) return;
try {
// Request defense
const result = await protestService.requestDefense({
protestId: protestDetail.protest?.id || protestDetail.protestId,
stewardId: currentDriverId,
@@ -266,8 +220,6 @@ export function ProtestDetailPageClient({ initialViewData }: { initialViewData:
if (result.isErr()) {
throw new Error(result.getError().message);
}
// Reload page to show updated status
window.location.reload();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to request defense');
@@ -291,519 +243,29 @@ export function ProtestDetailPageClient({ initialViewData }: { initialViewData:
}
};
// Show loading for admin check
if (adminLoading) {
return <LoadingWrapper variant="full-screen" message="Checking permissions..." />;
}
// Show access denied if not admin
if (!isAdmin) {
return (
<Card>
<Box p={12} textAlign="center">
<Box w={16} h={16} mx="auto" mb={4} rounded="full" bg="bg-iron-gray/50" display="flex" alignItems="center" justifyContent="center">
<UIIcon icon={AlertTriangle} size={8} color="text-warning-amber" />
</Box>
<Heading level={3}>Admin Access Required</Heading>
<Box mt={2}>
<Text size="sm" color="text-gray-400">
Only league admins can review protests.
</Text>
</Box>
</Box>
</Card>
);
}
return (
<StateContainer
data={protestDetail}
isLoading={detailLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading protest details...' },
error: { variant: 'full-screen' },
}}
>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{(pd: any) => {
if (!pd) return null;
const protest = pd.protest || pd;
const race = pd.race;
const protestingDriver = pd.protestingDriver;
const accusedDriver = pd.accusedDriver;
const statusConfig = getStatusConfig(protest.status);
const StatusIcon = statusConfig.icon;
const isPending = protest.status === 'pending';
const submittedAt = protest.submittedAt || pd.submittedAt;
const daysSinceFiled = Math.floor((Date.now() - new Date(submittedAt).getTime()) / (1000 * 60 * 60 * 24));
return (
<Box minHeight="100vh">
{/* Compact Header */}
<Box mb={6}>
<Stack direction="row" align="center" gap={3} mb={4}>
<UILink href={routes.league.stewarding(leagueId)}>
<UIIcon icon={ArrowLeft} size={5} color="text-gray-400" />
</UILink>
<Stack direction="row" align="center" gap={3} flexGrow={1}>
<Heading level={1}>Protest Review</Heading>
<Box display="flex" alignItems="center" gap={1.5} px={2.5} py={1} rounded="full" fontSize="0.75rem" weight="medium" border bg={statusConfig.bg} color={statusConfig.color} borderColor={statusConfig.borderColor}>
<UIIcon icon={StatusIcon} size={3} />
<Text>{statusConfig.label}</Text>
</Box>
{daysSinceFiled > 2 && isPending && (
<Box display="flex" alignItems="center" gap={1} px={2} py={0.5} fontSize="0.75rem" weight="medium" bg="bg-red-500/20" color="text-red-400" rounded="full">
<UIIcon icon={AlertTriangle} size={3} />
<Text>{daysSinceFiled}d old</Text>
</Box>
)}
</Stack>
</Stack>
</Box>
{/* Main Layout: Feed + Sidebar */}
<Grid cols={12} gap={6}>
{/* Left Sidebar - Incident Info */}
<GridItem colSpan={12} lgSpan={3}>
<Stack gap={4}>
{/* Drivers Involved */}
<Card>
<Box p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Parties Involved</Heading>
<Stack gap={3}>
{/* Protesting Driver */}
<UILink href={routes.driver.detail(protestingDriver?.id || '')} block>
<Box display="flex" alignItems="center" gap={3} p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-blue-500/50" hoverBg="bg-blue-500/5" transition cursor="pointer">
<Box w={10} h={10} rounded="full" bg="bg-blue-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<UIIcon icon={User} size={5} color="text-blue-400" />
</Box>
<Box flexGrow={1} minWidth="0">
<Text size="xs" color="text-blue-400" weight="medium" block>Protesting</Text>
<Text size="sm" weight="semibold" color="text-white" truncate block>{protestingDriver?.name || 'Unknown'}</Text>
</Box>
<UIIcon icon={ExternalLink} size={3} color="text-gray-500" />
</Box>
</UILink>
{/* Accused Driver */}
<UILink href={routes.driver.detail(accusedDriver?.id || '')} block>
<Box display="flex" alignItems="center" gap={3} p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-orange-500/50" hoverBg="bg-orange-500/5" transition cursor="pointer">
<Box w={10} h={10} rounded="full" bg="bg-orange-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<UIIcon icon={User} size={5} color="text-orange-400" />
</Box>
<Box flexGrow={1} minWidth="0">
<Text size="xs" color="text-orange-400" weight="medium" block>Accused</Text>
<Text size="sm" weight="semibold" color="text-white" truncate block>{accusedDriver?.name || 'Unknown'}</Text>
</Box>
<UIIcon icon={ExternalLink} size={3} color="text-gray-500" />
</Box>
</UILink>
</Stack>
</Box>
</Card>
{/* Race Info */}
<Card>
<Box p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Race Details</Heading>
<Box marginBottom={3}>
<UILink
href={routes.race.detail(race?.id || '')}
block
>
<Box p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-primary-blue/50" hoverBg="bg-primary-blue/5" transition>
<Box display="flex" alignItems="center" justifyContent="between">
<Text size="sm" weight="medium" color="text-white">{race?.name || 'Unknown Race'}</Text>
<UIIcon icon={ExternalLink} size={3} color="text-gray-500" />
</Box>
</Box>
</UILink>
</Box>
<Stack gap={2}>
<Box display="flex" alignItems="center" gap={2}>
<UIIcon icon={MapPin} size={4} color="text-gray-500" />
<Text size="sm" color="text-gray-300">{race?.name || 'Unknown Track'}</Text>
</Box>
<Box display="flex" alignItems="center" gap={2}>
<UIIcon icon={Calendar} size={4} color="text-gray-500" />
<Text size="sm" color="text-gray-300">{race?.formattedDate || (race?.scheduledAt ? new Date(race.scheduledAt).toLocaleDateString() : 'Unknown Date')}</Text>
</Box>
{protest.incident?.lap && (
<Box display="flex" alignItems="center" gap={2}>
<UIIcon icon={Flag} size={4} color="text-gray-500" />
<Text size="sm" color="text-gray-300">Lap {protest.incident.lap}</Text>
</Box>
)}
</Stack>
</Box>
</Card>
{protest.proofVideoUrl && (
<Card>
<Box p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Evidence</Heading>
<UILink
href={protest.proofVideoUrl}
target="_blank"
rel="noopener noreferrer"
block
>
<Box display="flex" alignItems="center" gap={2} p={3} rounded="lg" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" color="text-primary-blue" hoverBg="bg-primary-blue/20" transition>
<UIIcon icon={Video} size={4} />
<Text size="sm" weight="medium" flexGrow={1}>Watch Video</Text>
<UIIcon icon={ExternalLink} size={3} />
</Box>
</UILink>
</Box>
</Card>
)}
{/* Quick Stats */}
<Card>
<Box p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Timeline</Heading>
<Stack gap={2}>
<Box display="flex" justifyContent="between">
<Text size="sm" color="text-gray-500">Filed</Text>
<Text size="sm" color="text-gray-300">{new Date(submittedAt).toLocaleDateString()}</Text>
</Box>
<Box display="flex" justifyContent="between">
<Text size="sm" color="text-gray-500">Age</Text>
<Text size="sm" color={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</Text>
</Box>
{protest.reviewedAt && (
<Box display="flex" justifyContent="between">
<Text size="sm" color="text-gray-500">Resolved</Text>
<Text size="sm" color="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</Text>
</Box>
)}
</Stack>
</Box>
</Card>
</Stack>
</GridItem>
{/* Center - Discussion Feed */}
<GridItem colSpan={12} lgSpan={6}>
<Stack gap={4}>
{/* Timeline / Feed */}
<Card>
<Box borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/30" p={4}>
<Heading level={2}>Discussion</Heading>
</Box>
<Stack gap={0}>
{/* Initial Protest Filing */}
<Box p={4}>
<Box display="flex" gap={3}>
<Box w={10} h={10} rounded="full" bg="bg-blue-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<UIIcon icon={AlertCircle} size={5} color="text-blue-400" />
</Box>
<Box flexGrow={1} minWidth="0">
<Box display="flex" alignItems="center" gap={2} mb={1}>
<Text weight="semibold" color="text-white" size="sm">{protestingDriver?.name || 'Unknown'}</Text>
<Text size="xs" color="text-blue-400" weight="medium">filed protest</Text>
<Text size="xs" color="text-gray-500"></Text>
<Text size="xs" color="text-gray-500">{new Date(submittedAt).toLocaleString()}</Text>
</Box>
<Box bg="bg-deep-graphite" rounded="lg" p={4} border borderColor="border-charcoal-outline">
<Text size="sm" color="text-gray-300" block mb={3}>{protest.description || pd.incident?.description}</Text>
{(protest.comment || pd.comment) && (
<Box mt={3} pt={3} borderTop borderColor="border-charcoal-outline/50">
<Text size="xs" color="text-gray-500" block mb={1}>Additional details:</Text>
<Text size="sm" color="text-gray-400">{protest.comment || pd.comment}</Text>
</Box>
)}
</Box>
</Box>
</Box>
</Box>
{/* Defense placeholder */}
{protest.status === 'awaiting_defense' && (
<Box p={4} bg="bg-purple-500/5">
<Box display="flex" gap={3}>
<Box w={10} h={10} rounded="full" bg="bg-purple-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<UIIcon icon={MessageCircle} size={5} color="text-purple-400" />
</Box>
<Box flexGrow={1}>
<Text size="sm" color="text-purple-400" weight="medium" block mb={1}>Defense Requested</Text>
<Text size="sm" color="text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</Text>
</Box>
</Box>
</Box>
)}
{/* Decision (if resolved) */}
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
<Box p={4} bg={protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}>
<Box display="flex" gap={3}>
<Box w={10} h={10} rounded="full" display="flex" alignItems="center" justifyContent="center" flexShrink={0} bg={protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'}>
<UIIcon icon={Gavel} size={5} color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'} />
</Box>
<Box flexGrow={1} minWidth="0">
<Box display="flex" alignItems="center" gap={2} mb={1}>
<Text weight="semibold" color="text-white" size="sm">Steward Decision</Text>
<Text size="xs" weight="medium" color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}>
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
</Text>
{protest.reviewedAt && (
<>
<Text size="xs" color="text-gray-500"></Text>
<Text size="xs" color="text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</Text>
</>
)}
</Box>
<Box rounded="lg" p={4} border bg={protest.status === 'upheld' ? 'bg-red-500/10' : 'bg-gray-500/10'} borderColor={protest.status === 'upheld' ? 'border-red-500/20' : 'border-gray-500/20'}>
<Text size="sm" color="text-gray-300">{protest.decisionNotes}</Text>
</Box>
</Box>
</Box>
</Box>
)}
</Stack>
{/* Add Comment */}
{isPending && (
<Box p={4} borderTop borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
<Box display="flex" gap={3}>
<Box w={10} h={10} rounded="full" bg="bg-iron-gray" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<UIIcon icon={User} size={5} color="text-gray-500" />
</Box>
<Box flexGrow={1}>
<Box as="textarea"
value={newComment}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewComment(e.target.value)}
placeholder="Add a comment or request more information..."
style={{ height: '4rem' }}
w="full"
px={4}
py={3}
bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="lg"
color="text-white"
fontSize="sm"
/>
<Box display="flex" justifyContent="end" mt={2}>
<Button variant="secondary" disabled={!newComment.trim()}>
<UIIcon icon={Send} size={3} mr={1} />
Comment
</Button>
</Box>
</Box>
</Box>
</Box>
)}
</Card>
</Stack>
</GridItem>
{/* Right Sidebar - Actions */}
<GridItem colSpan={12} lgSpan={3}>
<Stack gap={4}>
{isPending && (
<>
{/* Quick Actions */}
<Card>
<Box p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Actions</Heading>
<Stack gap={2}>
<Button
variant="secondary"
fullWidth
onClick={handleRequestDefense}
>
<Stack direction="row" align="center" gap={2}>
<UIIcon icon={MessageCircle} size={4} />
<Text>Request Defense</Text>
</Stack>
</Button>
<Button
variant="primary"
fullWidth
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
>
<Stack direction="row" align="center" gap={2} fullWidth>
<UIIcon icon={Gavel} size={4} />
<Text>Make Decision</Text>
<Box ml="auto" transition transform={showDecisionPanel ? 'rotate(180deg)' : 'none'}>
<UIIcon icon={ChevronDown} size={4} />
</Box>
</Stack>
</Button>
</Stack>
</Box>
</Card>
{/* Decision Panel */}
{showDecisionPanel && (
<Card>
<Box p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Stewarding Decision</Heading>
{/* Decision Selection */}
<Grid cols={2} gap={2} mb={4}>
<Box padding={3} border borderColor={decision === 'uphold' ? 'border-racing-red' : 'border-charcoal-outline'} bg={decision === 'uphold' ? 'bg-racing-red/10' : 'transparent'} rounded="lg">
<Button
variant="ghost"
onClick={() => setDecision('uphold')}
fullWidth
>
<Stack align="center" gap={1}>
<UIIcon icon={CheckCircle} size={5} color={decision === 'uphold' ? 'text-red-400' : 'text-gray-500'} />
<Text size="xs" weight="medium" color={decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}>Uphold</Text>
</Stack>
</Button>
</Box>
<Box padding={3} border borderColor={decision === 'dismiss' ? 'border-gray-500' : 'border-charcoal-outline'} bg={decision === 'dismiss' ? 'bg-gray-500/10' : 'transparent'} rounded="lg">
<Button
variant="ghost"
onClick={() => setDecision('dismiss')}
fullWidth
>
<Stack align="center" gap={1}>
<UIIcon icon={XCircle} size={5} color={decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'} />
<Text size="xs" weight="medium" color={decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}>Dismiss</Text>
</Stack>
</Button>
</Box>
</Grid>
{/* Penalty Selection (if upholding) */}
{decision === 'uphold' && (
<Box mb={4}>
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={2}>Penalty Type</Text>
{penaltyTypes.length === 0 ? (
<Text size="xs" color="text-gray-500">
Loading penalty types...
</Text>
) : (
<>
<Grid cols={2} gap={2}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{penaltyTypes.map((penalty: any) => {
const Icon = penalty.icon;
const isSelected = penaltyType === penalty.type;
return (
<Box key={penalty.type} padding={2} border borderColor={isSelected ? undefined : 'border-charcoal-outline'} bg={isSelected ? undefined : 'bg-iron-gray/30'} rounded="lg">
<Button
variant="ghost"
onClick={() => {
setPenaltyType(penalty.type);
setPenaltyValue(penalty.defaultValue);
}}
fullWidth
title={penalty.description}
>
<Stack align="start" gap={0.5}>
<UIIcon icon={Icon} size={3.5} color={isSelected ? penalty.color : 'text-gray-500'} />
<Text size="xs" weight="medium" fontSize="10px" color={isSelected ? penalty.color : 'text-gray-500'}>
{penalty.label}
</Text>
</Stack>
</Button>
</Box>
);
})}
</Grid>
{selectedPenalty?.requiresValue && (
<Box mt={3}>
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1}>
Value ({selectedPenalty.valueLabel})
</Text>
<Box as="input"
type="number"
value={penaltyValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPenaltyValue(Number(e.target.value))}
min="1"
w="full"
px={3}
py={2}
bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="lg"
color="text-white"
fontSize="sm"
/>
</Box>
)}
</>
)}
</Box>
)}
{/* Steward Notes */}
<Box mb={4}>
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1}>Decision Reasoning *</Text>
<Box as="textarea"
value={stewardNotes}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setStewardNotes(e.target.value)}
placeholder="Explain your decision..."
style={{ height: '8rem' }}
w="full"
px={3}
py={2}
bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="lg"
color="text-white"
fontSize="sm"
/>
</Box>
{/* Submit */}
<Button
variant="primary"
fullWidth
onClick={handleSubmitDecision}
disabled={!decision || !stewardNotes.trim() || submitting}
>
{submitting ? 'Submitting...' : 'Submit Decision'}
</Button>
</Box>
</Card>
)}
</>
)}
{/* Already Resolved Info */}
{!isPending && (
<Card>
<Box p={4} textAlign="center">
<Box py={4} color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}>
<UIIcon icon={Gavel} size={8} mx="auto" mb={2} />
<Text weight="semibold" block>Case Closed</Text>
<Text size="xs" color="text-gray-500" block mt={1}>
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
</Text>
</Box>
</Box>
</Card>
)}
</Stack>
</GridItem>
</Grid>
</Box>
);
}}
</StateContainer>
<ProtestDetailTemplate
viewData={{}}
protestDetail={protestDetail}
leagueId={leagueId}
showDecisionPanel={showDecisionPanel}
setShowDecisionPanel={setShowDecisionPanel}
decision={decision}
setDecision={setDecision}
penaltyType={penaltyType}
setPenaltyType={setPenaltyType}
penaltyValue={penaltyValue}
setPenaltyValue={setPenaltyValue}
stewardNotes={stewardNotes}
setStewardNotes={setStewardNotes}
submitting={submitting}
newComment={newComment}
setNewComment={setNewComment}
penaltyTypes={penaltyTypes}
selectedPenalty={selectedPenalty}
onSubmitDecision={handleSubmitDecision}
onRequestDefense={handleRequestDefense}
getStatusConfig={getStatusConfig}
/>
);
}

View File

@@ -3,12 +3,9 @@
import React, { useState, useCallback } from 'react';
import { RaceDetailTemplate, RaceDetailViewData } from '@/templates/RaceDetailTemplate';
import { useRouter } from 'next/navigation';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface Props {
data: RaceDetailViewData;
}
export function RaceDetailPageClient({ data: viewData }: Props) {
export function RaceDetailPageClient({ viewData }: ClientWrapperProps<RaceDetailViewData>) {
const router = useRouter();
const [animatedRatingChange] = useState(0);

View File

@@ -4,12 +4,9 @@ import React, { useState, useCallback } from 'react';
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
import { RaceResultsViewData } from '@/lib/view-data/races/RaceResultsViewData';
import { useRouter } from 'next/navigation';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface Props {
data: RaceResultsViewData;
}
export function RaceResultsPageClient({ data: viewData }: Props) {
export function RaceResultsPageClient({ viewData }: ClientWrapperProps<RaceResultsViewData>) {
const router = useRouter();
const [importing, setImporting] = useState(false);
const [importSuccess, setImportSuccess] = useState(false);

View File

@@ -2,17 +2,17 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { StatefulPageWrapper } from '@/ui/StatefulPageWrapper';
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
import { RacesAllTemplate } from '@/templates/RacesAllTemplate';
import { useAllRacesPageData } from '@/hooks/race/useAllRacesPageData';
import { type RacesViewData, type RaceViewData } from '@/lib/view-data/RacesViewData';
import { Flag } from 'lucide-react';
import { routes } from '@/lib/routing/RouteConfig';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
const ITEMS_PER_PAGE = 10;
export function RacesAllPageClient({ initialViewData }: { initialViewData: RacesViewData | null }) {
export function RacesAllPageClient({ viewData: initialViewData }: ClientWrapperProps<RacesViewData>) {
const router = useRouter();
// Client-side state for filters and pagination
@@ -107,4 +107,4 @@ export function RacesAllPageClient({ initialViewData }: { initialViewData: Races
}}
/>
);
}
}

View File

@@ -4,12 +4,9 @@ import { useState, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { RacesTemplate, type TimeFilter, type RaceStatusFilter } from '@/templates/RacesTemplate';
import type { RacesViewData } from '@/lib/view-data/RacesViewData';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface RacesPageClientProps {
viewData: RacesViewData;
}
export function RacesPageClient({ viewData }: RacesPageClientProps) {
export function RacesPageClient({ viewData }: ClientWrapperProps<RacesViewData>) {
const router = useRouter();
const [statusFilter, setStatusFilter] = useState<RaceStatusFilter>('all');
const [leagueFilter, setLeagueFilter] = useState<string>('all');

View File

@@ -15,12 +15,9 @@ import { ResetPasswordViewModelBuilder } from '@/lib/builders/view-models/ResetP
import { ResetPasswordViewModel } from '@/lib/view-models/auth/ResetPasswordViewModel';
import { ResetPasswordFormValidation } from '@/lib/utilities/authValidation';
import { routes } from '@/lib/routing/RouteConfig';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface ResetPasswordClientProps {
viewData: ResetPasswordViewData;
}
export function ResetPasswordClient({ viewData }: ResetPasswordClientProps) {
export function ResetPasswordClient({ viewData }: ClientWrapperProps<ResetPasswordViewData>) {
const router = useRouter();
const searchParams = useSearchParams();

View File

@@ -12,13 +12,14 @@ import {
useRemoveMember,
} from "@/hooks/league/useLeagueRosterAdmin";
import { RosterAdminTemplate } from '@/templates/RosterAdminTemplate';
import type { JoinRequestData, RosterMemberData } from '@/lib/view-data/LeagueRosterAdminViewData';
import type { JoinRequestData, RosterMemberData, LeagueRosterAdminViewData } from '@/lib/view-data/LeagueRosterAdminViewData';
import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO';
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member'];
export function RosterAdminPage() {
export function RosterAdminPage({ viewData: initialViewData }: Partial<ClientWrapperProps<LeagueRosterAdminViewData>>) {
const params = useParams();
const leagueId = params.id as string;

View File

@@ -4,6 +4,7 @@ import React from 'react';
import { useRouter } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import { ServerErrorTemplate, type ServerErrorViewData } from '@/templates/ServerErrorTemplate';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
/**
* ServerErrorPageClient
@@ -11,7 +12,7 @@ import { ServerErrorTemplate, type ServerErrorViewData } from '@/templates/Serve
* Client-side entry point for the 500 page.
* Manages navigation and retry logic and wires it to the template.
*/
export function ServerErrorPageClient() {
export function ServerErrorPageClient({ viewData: initialViewData }: Partial<ClientWrapperProps<ServerErrorViewData>>) {
const router = useRouter();
const handleHome = () => {
@@ -25,7 +26,7 @@ export function ServerErrorPageClient() {
const error = new Error('Internal Server Error') as Error & { digest?: string };
error.digest = 'HTTP_500';
const viewData: ServerErrorViewData = {
const viewData: ServerErrorViewData = initialViewData ?? {
error,
incidentId: error.digest
};

View File

@@ -15,12 +15,9 @@ import { SignupMutation } from '@/lib/mutations/auth/SignupMutation';
import { SignupViewModelBuilder } from '@/lib/builders/view-models/SignupViewModelBuilder';
import { SignupViewModel } from '@/lib/view-models/auth/SignupViewModel';
import { SignupFormValidation } from '@/lib/utilities/authValidation';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface SignupClientProps {
viewData: SignupViewData;
}
export function SignupClient({ viewData }: SignupClientProps) {
export function SignupClient({ viewData }: ClientWrapperProps<SignupViewData>) {
const router = useRouter();
const searchParams = useSearchParams();
const { refreshSession } = useAuth();
@@ -39,7 +36,7 @@ export function SignupClient({ viewData }: SignupClientProps) {
...prev.formState,
fields: {
...prev.formState.fields,
[name]: {
[name as keyof typeof prev.formState.fields]: {
...prev.formState.fields[name as keyof typeof prev.formState.fields],
value,
touched: true,
@@ -75,7 +72,7 @@ export function SignupClient({ viewData }: SignupClientProps) {
...validationErrors.reduce((acc, error) => ({
...acc,
[error.field]: {
...prev.formState.fields[error.field],
...prev.formState.fields[error.field as keyof typeof prev.formState.fields],
error: error.message,
touched: true,
},
@@ -161,4 +158,4 @@ export function SignupClient({ viewData }: SignupClientProps) {
}}
/>
);
}
}

View File

@@ -2,14 +2,16 @@
import React, { useState } from 'react';
import { SponsorLeagueDetailTemplate } from '@/templates/SponsorLeagueDetailTemplate';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
export function SponsorLeagueDetailPageClient({ data }: { data: any }) {
export function SponsorLeagueDetailPageClient({ viewData }: ClientWrapperProps<ViewData>) {
const [activeTab, setActiveTab] = useState<'overview' | 'drivers' | 'races' | 'sponsor'>('overview');
const [selectedTier, setSelectedTier] = useState<'main' | 'secondary'>('main');
return (
<SponsorLeagueDetailTemplate
viewData={data}
viewData={viewData}
activeTab={activeTab}
setActiveTab={setActiveTab}
selectedTier={selectedTier}

View File

@@ -2,19 +2,19 @@
import React, { useState, useMemo } from 'react';
import { SponsorLeaguesTemplate, type SortOption, type TierFilter, type AvailabilityFilter } from '@/templates/SponsorLeaguesTemplate';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
export function SponsorLeaguesPageClient({ data }: { data: unknown }) {
export function SponsorLeaguesPageClient({ viewData }: ClientWrapperProps<ViewData>) {
const [searchQuery, setSearchQuery] = useState('');
const [tierFilter] = useState<TierFilter>('all');
const [availabilityFilter] = useState<AvailabilityFilter>('all');
const [sortBy] = useState<SortOption>('rating');
const filteredLeagues = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any;
const d = viewData as any;
if (!d?.leagues) return [];
return d.leagues
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.filter((league: any) => {
if (searchQuery && !league.name.toLowerCase().includes(searchQuery.toLowerCase())) {
return false;
@@ -30,7 +30,6 @@ export function SponsorLeaguesPageClient({ data }: { data: unknown }) {
}
return true;
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.sort((a: any, b: any) => {
switch (sortBy) {
case 'rating': return b.rating - a.rating;
@@ -40,12 +39,11 @@ export function SponsorLeaguesPageClient({ data }: { data: unknown }) {
default: return 0;
}
});
}, [data, searchQuery, tierFilter, availabilityFilter, sortBy]);
}, [viewData, searchQuery, tierFilter, availabilityFilter, sortBy]);
return (
<SponsorLeaguesTemplate
// eslint-disable-next-line @typescript-eslint/no-explicit-any
viewData={data as any}
viewData={viewData as any}
filteredLeagues={filteredLeagues}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}

View File

@@ -1,16 +1,21 @@
'use client';
import { InlineNotice } from '@/ui/InlineNotice';
import { ProgressLine } from '@/ui/ProgressLine';
import {
SharedBox,
SharedStack,
SharedText,
SharedIcon,
SharedProgressLine
} from '@/components/shared/UIComponents';
import { ShieldAlert } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import type { Result } from '@/lib/contracts/Result';
import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData';
import { SponsorshipRequestsTemplate } from '@/templates/SponsorshipRequestsTemplate';
import { Box } from '@/ui/Box';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
interface SponsorshipRequestsClientProps {
viewData: SponsorshipRequestsViewData;
interface SponsorshipRequestsClientProps extends ClientWrapperProps<SponsorshipRequestsViewData> {
onAccept: (requestId: string) => Promise<Result<void, string>>;
onReject: (requestId: string, reason?: string) => Promise<Result<void, string>>;
}
@@ -48,15 +53,19 @@ export function SponsorshipRequestsClient({ viewData, onAccept, onReject }: Spon
return (
<>
<ProgressLine isLoading={!!isProcessing} />
<SharedProgressLine isLoading={!!isProcessing} />
{error && (
<Box position="fixed" top={4} right={4} zIndex={50} maxWidth="md">
<InlineNotice
variant="error"
title="Action Failed"
message={error}
/>
</Box>
<SharedBox position="fixed" top={4} right={4} zIndex={50} maxWidth="md">
<SharedBox bg="bg-error-red/10" p={4} rounded="md" border borderColor="border-error-red/20">
<SharedStack direction="row" align="center" gap={3}>
<SharedIcon icon={ShieldAlert} size={5} color="text-error-red" />
<SharedBox>
<SharedText weight="bold" color="text-error-red">Action Failed</SharedText>
<SharedText size="sm" color="text-error-red/80">{error}</SharedText>
</SharedBox>
</SharedStack>
</SharedBox>
</SharedBox>
)}
<SponsorshipRequestsTemplate
viewData={viewData}

View File

@@ -3,9 +3,9 @@
import type { Result } from '@/lib/contracts/Result';
import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData';
import { SponsorshipRequestsTemplate } from '@/templates/SponsorshipRequestsTemplate';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface SponsorshipRequestsPageClientProps {
viewData: SponsorshipRequestsViewData;
interface SponsorshipRequestsPageClientProps extends ClientWrapperProps<SponsorshipRequestsViewData> {
onAccept: (requestId: string) => Promise<Result<void, string>>;
onReject: (requestId: string, reason?: string) => Promise<Result<void, string>>;
}

View File

@@ -1,31 +1,21 @@
'use client';
import { PenaltyHistoryList } from '@/components/leagues/PenaltyHistoryList';
import { QuickPenaltyModal } from '@/components/leagues/QuickPenaltyModal';
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
import { StewardingQueuePanel } from '@/components/leagues/StewardingQueuePanel';
import { StewardingStats } from '@/components/leagues/StewardingStats';
import { PenaltyFAB } from '@/components/races/PenaltyFAB';
import { useLeagueStewardingMutations } from "@/hooks/league/useLeagueStewardingMutations";
import type { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel';
import { RaceViewModel } from '@/lib/view-models/RaceViewModel';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { useMemo, useState } from 'react';
import { StewardingTemplate } from '@/templates/StewardingTemplate';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface StewardingTemplateProps {
data: StewardingViewData;
interface StewardingPageClientProps extends ClientWrapperProps<StewardingViewData> {
leagueId: string;
currentDriverId: string;
onRefetch: () => void;
}
export function StewardingPageClient({ data, currentDriverId, onRefetch }: StewardingTemplateProps) {
export function StewardingPageClient({ viewData, currentDriverId, onRefetch }: StewardingPageClientProps) {
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [selectedProtest, setSelectedProtest] = useState<ProtestViewModel | null>(null);
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
@@ -35,19 +25,19 @@ export function StewardingPageClient({ data, currentDriverId, onRefetch }: Stewa
// Flatten protests for the specialized list components
const allPendingProtests = useMemo(() => {
return data.races.flatMap(r => r.pendingProtests.map(p => ({
return viewData.races.flatMap(r => r.pendingProtests.map(p => ({
id: p.id,
raceName: r.track || 'Unknown Track',
protestingDriver: data.drivers.find(d => d.id === p.protestingDriverId)?.name || 'Unknown',
accusedDriver: data.drivers.find(d => d.id === p.accusedDriverId)?.name || 'Unknown',
protestingDriver: viewData.drivers.find(d => d.id === p.protestingDriverId)?.name || 'Unknown',
accusedDriver: viewData.drivers.find(d => d.id === p.accusedDriverId)?.name || 'Unknown',
description: p.incident.description,
submittedAt: p.filedAt,
status: p.status as 'pending' | 'under_review' | 'resolved' | 'rejected',
})));
}, [data.races, data.drivers]);
}, [viewData.races, viewData.drivers]);
const allResolvedProtests = useMemo(() => {
return data.races.flatMap(r => r.resolvedProtests.map(p => new ProtestViewModel({
return viewData.races.flatMap(r => r.resolvedProtests.map(p => new ProtestViewModel({
id: p.id,
protestingDriverId: p.protestingDriverId,
accusedDriverId: p.accusedDriverId,
@@ -59,11 +49,11 @@ export function StewardingPageClient({ data, currentDriverId, onRefetch }: Stewa
proofVideoUrl: p.proofVideoUrl,
decisionNotes: p.decisionNotes,
} as never)));
}, [data.races]);
}, [viewData.races]);
const racesMap = useMemo(() => {
const map: Record<string, RaceViewModel> = {};
data.races.forEach(r => {
viewData.races.forEach(r => {
map[r.id] = new RaceViewModel({
id: r.id,
name: '',
@@ -72,11 +62,11 @@ export function StewardingPageClient({ data, currentDriverId, onRefetch }: Stewa
} as never);
});
return map;
}, [data.races]);
}, [viewData.races]);
const driverMap = useMemo(() => {
const map: Record<string, DriverViewModel> = {};
data.drivers.forEach(d => {
viewData.drivers.forEach(d => {
map[d.id] = new DriverViewModel({
id: d.id,
name: d.name,
@@ -87,7 +77,7 @@ export function StewardingPageClient({ data, currentDriverId, onRefetch }: Stewa
});
});
return map;
}, [data.drivers]);
}, [viewData.drivers]);
const handleAcceptProtest = async (
protestId: string,
@@ -97,7 +87,7 @@ export function StewardingPageClient({ data, currentDriverId, onRefetch }: Stewa
) => {
// Find the protest to get details for penalty
let foundProtest: { raceId: string; accusedDriverId: string; incident: { description: string } } | undefined;
data.races.forEach((raceData) => {
viewData.races.forEach((raceData) => {
const p = raceData.pendingProtests.find((pr) => pr.id === protestId) ||
raceData.resolvedProtests.find((pr) => pr.id === protestId);
if (p) foundProtest = {
@@ -108,7 +98,7 @@ export function StewardingPageClient({ data, currentDriverId, onRefetch }: Stewa
});
if (foundProtest) {
acceptProtestMutation.mutate({
await acceptProtestMutation.mutateAsync({
protestId,
penaltyType,
penaltyValue,
@@ -121,7 +111,7 @@ export function StewardingPageClient({ data, currentDriverId, onRefetch }: Stewa
};
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
rejectProtestMutation.mutate({
await rejectProtestMutation.mutateAsync({
protestId,
stewardNotes,
});
@@ -130,7 +120,7 @@ export function StewardingPageClient({ data, currentDriverId, onRefetch }: Stewa
const handleReviewProtest = (id: string) => {
// Find the protest in the data
let foundProtest: ProtestViewModel | null = null;
data.races.forEach(r => {
viewData.races.forEach(r => {
const p = r.pendingProtests.find(p => p.id === id);
if (p) {
foundProtest = new ProtestViewModel({
@@ -151,96 +141,22 @@ export function StewardingPageClient({ data, currentDriverId, onRefetch }: Stewa
};
return (
<Stack gap={6}>
<StewardingStats
totalPending={data.totalPending}
totalResolved={data.totalResolved}
totalPenalties={data.totalPenalties}
/>
{/* Tab navigation */}
<Box borderBottom borderColor="border-charcoal-outline">
<Stack direction="row" gap={4}>
<Box
borderBottom={activeTab === 'pending'}
borderColor={activeTab === 'pending' ? 'border-primary-blue' : undefined}
>
<Button
variant="ghost"
onClick={() => setActiveTab('pending')}
rounded="none"
>
<Stack direction="row" align="center" gap={2}>
<Text weight="medium" color={activeTab === 'pending' ? 'text-primary-blue' : undefined}>Pending Protests</Text>
{data.totalPending > 0 && (
<Box px={2} py={0.5} fontSize="0.75rem" bg="bg-warning-amber/20" color="text-warning-amber" rounded="full">
{data.totalPending}
</Box>
)}
</Stack>
</Button>
</Box>
<Box
borderBottom={activeTab === 'history'}
borderColor={activeTab === 'history' ? 'border-primary-blue' : undefined}
>
<Button
variant="ghost"
onClick={() => setActiveTab('history')}
rounded="none"
>
<Text weight="medium" color={activeTab === 'history' ? 'text-primary-blue' : undefined}>History</Text>
</Button>
</Box>
</Stack>
</Box>
{/* Content */}
{activeTab === 'pending' ? (
<StewardingQueuePanel
protests={allPendingProtests}
onReview={handleReviewProtest}
/>
) : (
<Card>
<Box p={6}>
<PenaltyHistoryList
protests={allResolvedProtests}
races={racesMap}
drivers={driverMap}
/>
</Box>
</Card>
)}
{activeTab === 'history' && (
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
)}
{selectedProtest && (
<ReviewProtestModal
protest={selectedProtest}
onClose={() => setSelectedProtest(null)}
onAccept={handleAcceptProtest}
onReject={handleRejectProtest}
/>
)}
{showQuickPenaltyModal && (
<QuickPenaltyModal
drivers={data.drivers.map(d => new DriverViewModel({
id: d.id,
name: d.name,
iracingId: '',
country: '',
joinedAt: '',
avatarUrl: null,
}))}
onClose={() => setShowQuickPenaltyModal(false)}
adminId={currentDriverId || ''}
races={data.races.map(r => ({ id: r.id, track: r.track, scheduledAt: new Date(r.scheduledAt) }))}
/>
)}
</Stack>
<StewardingTemplate
viewData={viewData}
activeTab={activeTab}
onTabChange={setActiveTab}
selectedProtest={selectedProtest}
onReviewProtest={handleReviewProtest}
onCloseProtestModal={() => setSelectedProtest(null)}
onAcceptProtest={handleAcceptProtest}
onRejectProtest={handleRejectProtest}
showQuickPenaltyModal={showQuickPenaltyModal}
setShowQuickPenaltyModal={setShowQuickPenaltyModal}
allPendingProtests={allPendingProtests}
allResolvedProtests={allResolvedProtests}
racesMap={racesMap}
driverMap={driverMap}
currentDriverId={currentDriverId}
/>
);
}

View File

@@ -4,14 +4,11 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { TeamDetailViewData } from '@/lib/view-data/TeamDetailViewData';
import { TeamDetailTemplate } from '@/templates/TeamDetailTemplate';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
interface TeamDetailPageClientProps {
viewData: TeamDetailViewData;
}
export function TeamDetailPageClient({ viewData }: TeamDetailPageClientProps) {
export function TeamDetailPageClient({ viewData }: ClientWrapperProps<TeamDetailViewData>) {
const router = useRouter();
// UI state only (no business logic)
@@ -51,4 +48,4 @@ export function TeamDetailPageClient({ viewData }: TeamDetailPageClientProps) {
onGoBack={handleGoBack}
/>
);
}
}

View File

@@ -4,11 +4,17 @@ import { useRouter } from 'next/navigation';
import { TeamLeaderboardTemplate } from '@/templates/TeamLeaderboardTemplate';
import { useState } from 'react';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
export function TeamLeaderboardPageWrapper({ data }: { data: TeamSummaryViewModel[] | null }) {
interface TeamLeaderboardViewData extends ViewData {
teams: TeamSummaryViewModel[];
}
export function TeamLeaderboardPageWrapper({ viewData }: ClientWrapperProps<TeamLeaderboardViewData>) {
const router = useRouter();
// Client-side UI state only (no business logic)
@@ -16,7 +22,7 @@ export function TeamLeaderboardPageWrapper({ data }: { data: TeamSummaryViewMode
const [filterLevel, setFilterLevel] = useState<SkillLevel | 'all'>('all');
const [sortBy, setSortBy] = useState<SortBy>('rating');
if (!data || data.length === 0) {
if (!viewData.teams || viewData.teams.length === 0) {
return null;
}
@@ -28,17 +34,17 @@ export function TeamLeaderboardPageWrapper({ data }: { data: TeamSummaryViewMode
router.push('/teams');
};
const viewData = {
teams: data,
const templateViewData = {
teams: viewData.teams,
searchQuery,
filterLevel,
sortBy,
filteredAndSortedTeams: data,
filteredAndSortedTeams: viewData.teams,
};
return (
<TeamLeaderboardTemplate
viewData={viewData}
viewData={templateViewData}
onSearchChange={setSearchQuery}
filterLevelChange={setFilterLevel}
onSortChange={setSortBy}