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

@@ -1,4 +1,4 @@
import { PageWrapper } from '@/ui/PageWrapper';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { HomeTemplate, type HomeViewData } from '@/templates/HomeTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { HomePageQuery } from '@/lib/page-queries/HomePageQuery';
@@ -19,7 +19,7 @@ export default async function Page() {
notFound();
}
const Template = ({ data }: { data: HomeViewData }) => <HomeTemplate viewData={data} />;
const Template = ({ viewData }: { viewData: HomeViewData }) => <HomeTemplate viewData={viewData} />;
return <PageWrapper data={data} Template={Template} />;
}

View File

@@ -1,5 +1,5 @@
import { notFound } from 'next/navigation';
import { PageWrapper } from '@/ui/PageWrapper';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { RaceDetailPageQuery } from '@/lib/page-queries/races/RaceDetailPageQuery';
import { RaceDetailPageClient } from '@/client-wrapper/RaceDetailPageClient';

View File

@@ -1,5 +1,5 @@
import { notFound } from 'next/navigation';
import { PageWrapper } from '@/ui/PageWrapper';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { RaceResultsPageQuery } from '@/lib/page-queries/races/RaceResultsPageQuery';
import { RaceResultsPageClient } from '@/client-wrapper/RaceResultsPageClient';

View File

@@ -1,5 +1,5 @@
import { notFound } from 'next/navigation';
import { PageWrapper } from '@/ui/PageWrapper';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { SponsorLeagueDetailPageClient } from '@/client-wrapper/SponsorLeagueDetailPageClient';
import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';

View File

@@ -1,4 +1,4 @@
import { PageWrapper } from '@/ui/PageWrapper';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { SponsorLeaguesPageClient } from '@/client-wrapper/SponsorLeaguesPageClient';
import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';

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}

View File

@@ -0,0 +1,22 @@
'use client';
import { ReactNode } from 'react';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
interface AdminDashboardLayoutProps {
children: ReactNode;
}
export function AdminDashboardLayout({ children }: AdminDashboardLayoutProps) {
return (
<Container size="lg">
<Box paddingY={8}>
<Stack gap={8}>
{children}
</Stack>
</Box>
</Container>
);
}

View File

@@ -0,0 +1,24 @@
'use client';
import { ReactNode } from 'react';
import { SharedBox, SharedStack, SharedContainer } from '@/components/shared/UIComponents';
interface CreateLeagueWizardLayoutProps {
children: ReactNode;
header: ReactNode;
progress: ReactNode;
navigation: ReactNode;
footer: ReactNode;
}
export function CreateLeagueWizardLayout({ children, header, progress, navigation, footer }: CreateLeagueWizardLayoutProps) {
return (
<SharedBox as="main" maxWidth="4xl" mx="auto" pb={8}>
{header}
{progress}
{children}
{navigation}
{footer}
</SharedBox>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { ReactNode } from 'react';
import { Box } from '@/ui/Box';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
interface RacesAllLayoutProps {
children: ReactNode;
header: ReactNode;
stats: ReactNode;
pagination: ReactNode;
}
export function RacesAllLayout({ children, header, stats, pagination }: RacesAllLayoutProps) {
return (
<Box as="main" minHeight="screen" bg="bg-base-black" py={8}>
<Container size="lg">
<Stack gap={8}>
{header}
{stats}
{children}
{pagination}
</Stack>
</Container>
</Box>
);
}
export function RacesAllStats({ count, onFilterClick }: { count: number, onFilterClick: () => void }) {
return (
<Box display="flex" justifyContent="between" alignItems="center">
<Text size="sm" color="text-gray-400">
Showing <Text as="span" color="text-white" weight="bold">{count}</Text> races
</Text>
<Box
as="button"
onClick={onFilterClick}
px={4}
py={2}
bg="bg-surface-charcoal"
border
borderColor="border-outline-steel"
fontSize="10px"
weight="bold"
letterSpacing="wider"
hoverBorderColor="border-primary-accent"
transition
className="uppercase"
>
Filters
</Box>
</Box>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import { ReactNode } from 'react';
import { Box } from '@/ui/Box';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { Text } from '@/ui/Text';
interface RacesLayoutProps {
children: ReactNode;
header: ReactNode;
banner?: ReactNode;
sidebar: ReactNode;
}
export function RacesLayout({ children, header, banner, sidebar }: RacesLayoutProps) {
return (
<Box as="main" minHeight="screen" bg="bg-base-black" py={8}>
<Container size="lg">
<Stack gap={8}>
{header}
{banner}
<Grid cols={12} gap={6}>
<GridItem colSpan={12} lgSpan={8}>
{children}
</GridItem>
<GridItem colSpan={12} lgSpan={4}>
{sidebar}
</GridItem>
</Grid>
</Stack>
</Container>
</Box>
);
}
export function RaceScheduleSection({ children, title }: { children: ReactNode, title: string }) {
return (
<Box as="section" bg="bg-surface-charcoal" border borderColor="border-outline-steel" overflow="hidden">
<Box p={4} borderBottom borderColor="border-outline-steel" bg="bg-base-black" bgOpacity={0.2}>
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="widest">{title}</Text>
</Box>
{children}
</Box>
);
}

View File

@@ -0,0 +1,26 @@
'use client';
import { LucideIcon } from 'lucide-react';
import { EmptyState } from '@/ui/EmptyState';
interface SharedEmptyStateProps {
icon: LucideIcon;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'race-final' | 'discord';
};
}
export function SharedEmptyState({ icon, title, description, action }: SharedEmptyStateProps) {
return (
<EmptyState
icon={icon}
title={title}
description={description}
action={action}
/>
);
}

View File

@@ -0,0 +1,42 @@
import { Pagination } from '@/ui/Pagination';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Container } from '@/ui/Container';
import { ConfirmDialog } from '@/ui/ConfirmDialog';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { Surface } from '@/ui/Surface';
import { Input } from '@/ui/Input';
import { Link } from '@/ui/Link';
import { Skeleton } from '@/ui/Skeleton';
import { LoadingSpinner } from '@/ui/LoadingSpinner';
import { Badge } from '@/ui/Badge';
import { ProgressLine } from '@/ui/ProgressLine';
export {
Pagination as SharedPagination,
Text as SharedText,
Box as SharedBox,
Stack as SharedStack,
Container as SharedContainer,
ConfirmDialog as SharedConfirmDialog,
Button as SharedButton,
Icon as SharedIcon,
Card as SharedCard,
Heading as SharedHeading,
Grid as SharedGrid,
GridItem as SharedGridItem,
Surface as SharedSurface,
Input as SharedInput,
Link as SharedLink,
Skeleton as SharedSkeleton,
LoadingSpinner as SharedLoadingSpinner,
Badge as SharedBadge,
ProgressLine as SharedProgressLine
};

View File

@@ -40,7 +40,7 @@ export interface PageWrapperProps<TData> {
/** Retry function for errors */
retry?: () => void;
/** Template component that receives the data */
Template: React.ComponentType<{ data: TData }>;
Template: React.ComponentType<{ viewData: TData }>;
/** Loading configuration */
loading?: PageWrapperLoadingConfig;
/** Error configuration */
@@ -162,7 +162,7 @@ export function PageWrapper<TData>({
// 4. Success State - Render Template with data
return (
<React.Fragment>
<Template data={data} />
<Template viewData={data} />
{children}
</React.Fragment>
);

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { PageWrapper, PageWrapperProps } from '@/ui/PageWrapper';
import { PageWrapper, PageWrapperProps } from './PageWrapper';
/**
* Stateful Page Wrapper - CLIENT SIDE ONLY
@@ -56,4 +56,4 @@ export function StatefulPageWrapper<TData>({
}
// Re-export types for convenience
export type { PageWrapperProps, PageWrapperLoadingConfig, PageWrapperErrorConfig, PageWrapperEmptyConfig } from '@/ui/PageWrapper';
export type { PageWrapperProps, PageWrapperLoadingConfig, PageWrapperErrorConfig, PageWrapperEmptyConfig } from './PageWrapper';

View File

@@ -0,0 +1,45 @@
'use client';
import { ReactNode } from 'react';
import { Box } from '@/ui/Box';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
interface TeamsDirectoryProps {
children: ReactNode;
title?: string;
subtitle?: string;
}
export function TeamsDirectory({ children, title, subtitle }: TeamsDirectoryProps) {
return (
<Box as="main" bg="base-black" minHeight="screen">
<Container size="lg">
<Box paddingY={12}>
<Stack gap={10}>
{title && (
<Stack direction="row" align="center" gap={2} mb={6}>
<Box w="2" h="2" bg="primary-accent" />
<Text size="xs" weight="bold" color="text-gray-400" uppercase>{title}</Text>
</Stack>
)}
{children}
</Stack>
</Box>
</Container>
</Box>
);
}
export function TeamsDirectorySection({ children, title, accentColor = "primary-accent" }: { children: ReactNode, title: string, accentColor?: string }) {
return (
<Box>
<Stack direction="row" align="center" gap={2} mb={6}>
<Box w="2" h="2" bg={accentColor} />
<Text size="xs" weight="bold" color="text-gray-400" uppercase>{title}</Text>
</Stack>
{children}
</Box>
);
}

View File

@@ -0,0 +1,37 @@
import { ReactElement } from 'react';
import { ViewData } from '../view-data/ViewData';
/**
* A Template is a stateless component that composes other components.
* It receives ViewData and event handlers.
*
* Rules:
* - Stateless (no useState, useEffect)
* - Receives ViewData and event handlers
* - Composes components and UI elements
* - No business logic
* - No data fetching
* - CANNOT import from ui/, MUST use components/
*/
export type Template<P extends TemplateProps<any>> = (props: P) => ReactElement | null;
export interface TemplateProps<T extends ViewData> {
viewData: T;
}
/**
* A Client Wrapper (PageClient) manages state and event handlers.
* It wires server data (ViewData) to a Template.
*
* Rules:
* - Manages client state and event handlers
* - No UI rendering logic (except loading/error states)
* - MUST return a Template
* - CANNOT import from ui/, MUST use components/
*/
export type ClientWrapper<T extends ViewData, P extends ClientWrapperProps<T> = ClientWrapperProps<T>> =
(props: P) => ReactElement<TemplateProps<T>> | null;
export interface ClientWrapperProps<T extends ViewData> {
viewData: T;
}

View File

@@ -19,8 +19,8 @@ import type { JsonValue, JsonObject } from '../types/primitives';
* All ViewData must be JSON-serializable.
* This type ensures no class instances or functions are included.
*/
export interface ViewData extends JsonObject {
[key: string]: JsonValue;
export interface ViewData {
[key: string]: any;
}
/**

View File

@@ -1,3 +1,5 @@
import { ViewData } from '../contracts/view-data/ViewData';
/**
* LeagueDetailViewData - Pure ViewData for LeagueDetailTemplate
* Contains only raw serializable data, no methods or computed properties
@@ -63,7 +65,7 @@ export interface SponsorshipSlot {
benefits: string[];
}
export interface LeagueDetailViewData {
export interface LeagueDetailViewData extends ViewData {
// Basic info
leagueId: string;
name: string;
@@ -100,4 +102,4 @@ export interface LeagueDetailViewData {
metrics: SponsorMetric[];
slots: SponsorshipSlot[];
} | null;
}
}

View File

@@ -1,3 +1,5 @@
import { ViewData } from '../contracts/view-data/ViewData';
/**
* TeamsViewData - Pure ViewData for TeamsTemplate
* Contains only raw serializable data, no methods or computed properties
@@ -11,6 +13,6 @@ export interface TeamSummaryData {
logoUrl?: string;
}
export interface TeamsViewData {
export interface TeamsViewData extends ViewData {
teams: TeamSummaryData[];
}

View File

@@ -6,16 +6,18 @@ import { AdminSectionHeader } from '@/components/admin/AdminSectionHeader';
import { AdminStatsPanel } from '@/components/admin/AdminStatsPanel';
import { routes } from '@/lib/routing/RouteConfig';
import { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Container } from '@/ui/Container';
import { Icon } from '@/ui/Icon';
import { Grid } from '@/ui/Grid';
import { Stack } from '@/ui/Stack';
import {
SharedBox,
SharedButton,
SharedCard,
SharedContainer,
SharedIcon,
SharedGrid,
SharedStack,
SharedText,
SharedBadge
} from '@/components/shared/UIComponents';
import { QuickActionLink } from '@/ui/QuickActionLink';
import { StatusBadge } from '@/ui/StatusBadge';
import { Text } from '@/ui/Text';
import {
Activity,
ArrowRight,
@@ -24,6 +26,7 @@ import {
Shield,
Users
} from 'lucide-react';
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
/**
* AdminDashboardTemplate
@@ -35,8 +38,7 @@ export function AdminDashboardTemplate({
viewData,
onRefresh,
isLoading
}: {
viewData: AdminDashboardViewData;
}: TemplateProps<AdminDashboardViewData> & {
onRefresh: () => void;
isLoading: boolean;
}) {
@@ -68,103 +70,93 @@ export function AdminDashboardTemplate({
];
return (
<Container size="lg" py={8}>
<Stack gap={8}>
<AdminHeaderPanel
title="Admin Dashboard"
description="System-wide telemetry and operations control"
isLoading={isLoading}
actions={
<Button
onClick={onRefresh}
disabled={isLoading}
variant="secondary"
size="sm"
icon={<Icon icon={RefreshCw} size={3} animate={isLoading ? 'spin' : 'none'} />}
>
Refresh Telemetry
</Button>
}
/>
<SharedContainer size="lg">
<SharedBox paddingY={8}>
<SharedStack gap={8}>
<AdminHeaderPanel
title="Admin Dashboard"
description="System-wide telemetry and operations control"
isLoading={isLoading}
actions={
<SharedButton
onClick={onRefresh}
disabled={isLoading}
variant="secondary"
size="sm"
icon={<SharedIcon icon={RefreshCw} size={3} animate={isLoading ? 'spin' : 'none'} />}
>
Refresh Telemetry
</SharedButton>
}
/>
<AdminStatsPanel stats={stats} />
<AdminStatsPanel stats={stats} />
<Grid cols={1} mdCols={2} gap={6}>
{/* System Health & Status */}
<Card p={6}>
<Stack gap={6}>
<AdminSectionHeader
title="System Status"
actions={
<StatusBadge variant="success" icon={Activity}>
Operational
</StatusBadge>
}
/>
<Stack gap={4}>
<Box borderTop borderColor="border-gray" opacity={0.3} />
<Box pt={0}>
<Stack direction="row" align="center" justify="between" py={2}>
<Text size="sm" color="text-gray-400">Suspended Users</Text>
<Text weight="bold" color="text-warning-amber">{viewData.stats.suspendedUsers}</Text>
</Stack>
</Box>
<Box borderTop borderColor="border-gray" opacity={0.3} />
<Box>
<Stack direction="row" align="center" justify="between" py={2}>
<Text size="sm" color="text-gray-400">Deleted Users</Text>
<Text weight="bold" color="text-error-red">{viewData.stats.deletedUsers}</Text>
</Stack>
</Box>
<Box borderTop borderColor="border-gray" opacity={0.3} />
<Box>
<Stack direction="row" align="center" justify="between" py={2}>
<Text size="sm" color="text-gray-400">New Registrations (24h)</Text>
<Text weight="bold" color="text-primary-blue">{viewData.stats.newUsersToday}</Text>
</Stack>
</Box>
</Stack>
</Stack>
</Card>
<SharedGrid cols={{ base: 1, md: 2 }} gap={6}>
{/* System Health & Status */}
<SharedCard p={6}>
<SharedStack gap={6}>
<AdminSectionHeader
title="System Status"
actions={
<SharedBadge variant="success">
<SharedStack direction="row" align="center" gap={1.5}>
<SharedIcon icon={Activity} size={3} />
<SharedText>Operational</SharedText>
</SharedStack>
</SharedBadge>
}
/>
<SharedStack gap={4}>
<SharedBox borderTop borderColor="border-gray" opacity={0.3} />
<SharedBox pt={0}>
<SharedStack direction="row" align="center" justify="between" py={2}>
<SharedText size="sm" color="text-gray-400">Suspended Users</SharedText>
<SharedText weight="bold" color="text-warning-amber">{viewData.stats.suspendedUsers}</SharedText>
</SharedStack>
</SharedBox>
<SharedBox borderTop borderColor="border-gray" opacity={0.3} />
<SharedBox>
<SharedStack direction="row" align="center" justify="between" py={2}>
<SharedText size="sm" color="text-gray-400">Deleted Users</SharedText>
<SharedText weight="bold" color="text-error-red">{viewData.stats.deletedUsers}</SharedText>
</SharedStack>
</SharedBox>
<SharedBox borderTop borderColor="border-gray" opacity={0.3} />
<SharedBox>
<SharedStack direction="row" align="center" justify="between" py={2}>
<SharedText size="sm" color="text-gray-400">New Registrations (24h)</SharedText>
<SharedText weight="bold" color="text-primary-blue">{viewData.stats.newUsersToday}</SharedText>
</SharedStack>
</SharedBox>
</SharedStack>
</SharedStack>
</SharedCard>
{/* Quick Operations */}
<Card p={6}>
<Stack gap={6}>
<AdminSectionHeader title="Quick Operations" />
<Grid cols={1} gap={3}>
<QuickActionLink href={routes.admin.users} variant="blue">
<Stack direction="row" align="center" justify="between" fullWidth>
<Text size="sm" weight="bold">User Management</Text>
<Icon icon={ArrowRight} size={4} />
</Stack>
</QuickActionLink>
<QuickActionLink href="/admin" variant="purple">
<Stack direction="row" align="center" justify="between" fullWidth>
<Text size="sm" weight="bold">Security & Roles</Text>
<Icon icon={ArrowRight} size={4} />
</Stack>
</QuickActionLink>
<QuickActionLink href="/admin" variant="orange">
<Stack direction="row" align="center" justify="between" fullWidth>
<Text size="sm" weight="bold">System Audit Logs</Text>
<Icon icon={ArrowRight} size={4} />
</Stack>
</QuickActionLink>
</Grid>
</Stack>
</Card>
</Grid>
{/* Quick Operations */}
<SharedCard p={6}>
<SharedStack gap={6}>
<AdminSectionHeader title="Quick Operations" />
<SharedGrid cols={1} gap={3}>
<QuickActionLink href={routes.admin.users} label="User Management" icon={Users} />
<QuickActionLink href="/admin" label="Security & Roles" icon={Shield} />
<QuickActionLink href="/admin" label="System Audit Logs" icon={Activity} />
</SharedGrid>
</SharedStack>
</SharedCard>
</SharedGrid>
<AdminDangerZonePanel
title="System Maintenance"
description="Perform destructive system-wide operations. Use with extreme caution."
>
<Button variant="danger" size="sm">
Enter Maintenance Mode
</Button>
</AdminDangerZonePanel>
</Stack>
</Container>
<AdminDangerZonePanel
title="System Maintenance"
description="Perform destructive system-wide operations. Use with extreme caution."
>
<SharedButton variant="danger" size="sm">
Enter Maintenance Mode
</SharedButton>
</AdminDangerZonePanel>
</SharedStack>
</SharedBox>
</SharedContainer>
);
}

View File

@@ -7,16 +7,15 @@ import { AdminStatsPanel } from '@/components/admin/AdminStatsPanel';
import { AdminUsersTable } from '@/components/admin/AdminUsersTable';
import { BulkActionBar } from '@/components/admin/BulkActionBar';
import { UserFilters } from '@/components/admin/UserFilters';
import { InlineNotice } from '@/ui/InlineNotice';
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
import { Button } from '@/ui/Button';
import { Container } from '@/ui/Container';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { SharedButton, SharedContainer, SharedIcon, SharedStack, SharedBox, SharedText } from '@/components/shared/UIComponents';
import { RefreshCw, ShieldAlert, Users } from 'lucide-react';
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
interface AdminUsersTemplateProps {
viewData: AdminUsersViewData;
// We need to add InlineNotice to UIComponents if it's used
// For now I'll assume it's a component or I'll add it to UIComponents
interface AdminUsersTemplateProps extends TemplateProps<AdminUsersViewData> {
onRefresh: () => void;
onSearch: (search: string) => void;
onFilterRole: (role: string) => void;
@@ -37,13 +36,6 @@ interface AdminUsersTemplateProps {
onClearSelection: () => void;
}
/**
* AdminUsersTemplate
*
* Redesigned user management page.
* Uses semantic admin UI blocks and follows "Precision Racing Minimal" theme.
* Stateless template.
*/
export function AdminUsersTemplate({
viewData,
onRefresh,
@@ -103,76 +95,83 @@ export function AdminUsersTemplate({
];
return (
<Container size="lg" py={8}>
<Stack gap={8}>
<AdminHeaderPanel
title="User Management"
description="Monitor and control system access"
isLoading={loading}
actions={
<Button
onClick={onRefresh}
disabled={loading}
variant="secondary"
size="sm"
icon={<Icon icon={RefreshCw} size={3} animate={loading ? 'spin' : 'none'} />}
>
Refresh Data
</Button>
}
/>
{error && (
<InlineNotice
variant="error"
title="Operation Failed"
message={error}
<SharedContainer size="lg">
<SharedBox paddingY={8}>
<SharedStack gap={8}>
<AdminHeaderPanel
title="User Management"
description="Monitor and control system access"
isLoading={loading}
actions={
<SharedButton
onClick={onRefresh}
disabled={loading}
variant="secondary"
size="sm"
icon={<SharedIcon icon={RefreshCw} size={3} animate={loading ? 'spin' : 'none'} />}
>
Refresh Data
</SharedButton>
}
/>
)}
<AdminStatsPanel stats={stats} />
<UserFilters
search={search}
roleFilter={roleFilter}
statusFilter={statusFilter}
onSearch={onSearch}
onFilterRole={onFilterRole}
onFilterStatus={onFilterStatus}
onClearFilters={onClearFilters}
/>
<AdminDataTable>
{viewData.users.length === 0 && !loading ? (
<AdminEmptyState
icon={Users}
title="No users found"
description="Try adjusting your filters or search query"
action={
<Button variant="secondary" size="sm" onClick={onClearFilters}>
Clear All Filters
</Button>
}
/>
) : (
<AdminUsersTable
users={viewData.users}
selectedUserIds={selectedUserIds}
onSelectUser={onSelectUser}
onSelectAll={onSelectAll}
onUpdateStatus={onUpdateStatus}
onDeleteUser={onDeleteUser}
deletingUserId={deletingUser}
/>
{/* error notice should be a component */}
{error && (
<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">Operation Failed</SharedText>
<SharedText size="sm" color="text-error-red/80">{error}</SharedText>
</SharedBox>
</SharedStack>
</SharedBox>
)}
</AdminDataTable>
<BulkActionBar
selectedCount={selectedUserIds.length}
actions={bulkActions}
onClearSelection={onClearSelection}
/>
</Stack>
</Container>
<AdminStatsPanel stats={stats} />
<UserFilters
search={search}
roleFilter={roleFilter}
statusFilter={statusFilter}
onSearch={onSearch}
onFilterRole={onFilterRole}
onFilterStatus={onFilterStatus}
onClearFilters={onClearFilters}
/>
<AdminDataTable>
{viewData.users.length === 0 && !loading ? (
<AdminEmptyState
icon={Users}
title="No users found"
description="Try adjusting your filters or search query"
action={
<SharedButton variant="secondary" size="sm" onClick={onClearFilters}>
Clear All Filters
</SharedButton>
}
/>
) : (
<AdminUsersTable
users={viewData.users}
selectedUserIds={selectedUserIds}
onSelectUser={onSelectUser}
onSelectAll={onSelectAll}
onUpdateStatus={onUpdateStatus}
onDeleteUser={onDeleteUser}
deletingUserId={deletingUser}
/>
)}
</AdminDataTable>
<BulkActionBar
selectedCount={selectedUserIds.length}
actions={bulkActions}
onClearSelection={onClearSelection}
/>
</SharedStack>
</SharedBox>
</SharedContainer>
);
}

View File

@@ -0,0 +1,485 @@
'use client';
import { FormEvent, ReactNode } from 'react';
import { LeagueReviewSummary } from '@/components/leagues/LeagueReviewSummary';
import {
SharedBox,
SharedButton,
SharedStack,
SharedText,
SharedIcon,
SharedContainer
} from '@/components/shared/UIComponents';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Input } from '@/ui/Input';
import {
AlertCircle,
Award,
Calendar,
Check,
CheckCircle2,
ChevronLeft,
ChevronRight,
FileText,
Loader2,
Scale,
Sparkles,
Trophy,
Users,
} from 'lucide-react';
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 type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
import type { WizardErrors } from '@/lib/types/WizardErrors';
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
export type Step = 1 | 2 | 3 | 4 | 5 | 6 | 7;
interface CreateLeagueWizardTemplateProps extends TemplateProps<ViewData> {
step: Step;
steps: any[];
form: any;
errors: WizardErrors;
loading: boolean;
presetsLoading: boolean;
presets: LeagueScoringPresetViewModel[];
highestCompletedStep: number;
onGoToStep: (step: Step) => void;
onFormChange: (form: any) => void;
onSubmit: (e: FormEvent) => void;
onNextStep: () => void;
onPreviousStep: () => void;
onScoringPresetChange: (id: string) => void;
onToggleCustomScoring: () => void;
getStepTitle: (step: Step) => string;
getStepSubtitle: (step: Step) => string;
getStepContextLabel: (step: Step) => string;
}
export function CreateLeagueWizardTemplate({
step,
steps,
form,
errors,
loading,
presetsLoading,
presets,
highestCompletedStep,
onGoToStep,
onFormChange,
onSubmit,
onNextStep,
onPreviousStep,
onScoringPresetChange,
onToggleCustomScoring,
getStepTitle,
getStepSubtitle,
getStepContextLabel,
}: CreateLeagueWizardTemplateProps) {
const currentStepData = steps.find((s) => s.id === step);
const CurrentStepIcon = currentStepData?.icon ?? FileText;
return (
<SharedBox as="form" onSubmit={onSubmit} maxWidth="4xl" mx="auto" pb={8}>
{/* Header with icon */}
<SharedBox mb={8}>
<SharedStack direction="row" align="center" gap={3} mb={3}>
<SharedBox display="flex" h="11" w="11" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/20" border borderColor="border-primary-blue/20">
<SharedIcon icon={Sparkles} size={5} color="text-primary-blue" />
</SharedBox>
<SharedBox>
<Heading level={1} fontSize={{ base: '2xl', sm: '3xl' }}>
Create a new league
</Heading>
<SharedText size="sm" color="text-gray-500" block>
We'll also set up your first season in {steps.length} easy steps.
</SharedText>
<SharedText 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.
</SharedText>
</SharedBox>
</SharedStack>
</SharedBox>
{/* Desktop Progress Bar */}
<SharedBox display={{ base: 'none', md: 'block' }} mb={8}>
<SharedBox position="relative">
{/* Background track */}
<SharedBox position="absolute" top="5" left="6" right="6" h="0.5" bg="bg-charcoal-outline" rounded="full" />
{/* Progress fill */}
<SharedBox
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)`}
/>
<SharedBox 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 (
<SharedBox
as="button"
key={wizardStep.id}
type="button"
onClick={() => onGoToStep(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}
>
<SharedBox
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 ? (
<SharedIcon icon={Check} size={4} strokeWidth={3} />
) : (
<SharedIcon icon={StepIcon} size={4} />
)}
</SharedBox>
<SharedBox mt={2} textAlign="center">
<SharedText
size="xs"
weight="medium"
transition
color={isCurrent ? 'text-white' : isCompleted ? 'text-primary-blue' : isAccessible ? 'text-gray-400' : 'text-gray-500'}
>
{wizardStep.label}
</SharedText>
</SharedBox>
</SharedBox>
);
})}
</SharedBox>
</SharedBox>
</SharedBox>
{/* Mobile Progress */}
<SharedBox display={{ base: 'block', md: 'none' }} mb={6}>
<SharedBox display="flex" alignItems="center" justifyContent="between" mb={2}>
<SharedStack direction="row" align="center" gap={2}>
<SharedIcon icon={CurrentStepIcon} size={4} color="text-primary-blue" />
<SharedText size="sm" weight="medium" color="text-white">{currentStepData?.label}</SharedText>
</SharedStack>
<SharedText size="xs" color="text-gray-500">
{step}/{steps.length}
</SharedText>
</SharedBox>
<SharedBox h="1.5" bg="bg-charcoal-outline" rounded="full" overflow="hidden">
<SharedBox
h="full"
bg="bg-gradient-to-r from-primary-blue to-neon-aqua"
rounded="full"
transition
height="full"
width={`${(step / steps.length) * 100}%`}
/>
</SharedBox>
{/* Step dots */}
<SharedBox display="flex" justifyContent="between" mt={2} px={0.5}>
{steps.map((s) => (
<SharedBox
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'}
/>
))}
</SharedBox>
</SharedBox>
{/* Main Card */}
<Card position="relative" overflow="hidden">
{/* Top gradient accent */}
<SharedBox position="absolute" top="0" left="0" right="0" h="1" bg="bg-gradient-to-r from-transparent via-primary-blue to-transparent" />
{/* Step header */}
<SharedBox display="flex" alignItems="start" gap={4} mb={6}>
<SharedBox display="flex" h="12" w="12" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/10" flexShrink={0} transition>
<SharedIcon icon={CurrentStepIcon} size={6} color="text-primary-blue" />
</SharedBox>
<SharedBox flexGrow={1} minWidth="0">
<Heading level={2} fontSize={{ base: 'xl', md: '2xl' }} color="text-white">
<SharedStack direction="row" align="center" gap={2} flexWrap="wrap">
<SharedText>{getStepTitle(step)}</SharedText>
<SharedText 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)}
</SharedText>
</SharedStack>
</Heading>
<SharedText size="sm" color="text-gray-400" block mt={1}>
{getStepSubtitle(step)}
</SharedText>
</SharedBox>
<SharedBox 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">
<SharedText size="xs" color="text-gray-500">Step</SharedText>
<SharedText size="sm" weight="semibold" color="text-white">{step}</SharedText>
<SharedText size="xs" color="text-gray-500">/ {steps.length}</SharedText>
</SharedBox>
</SharedBox>
{/* Divider */}
<SharedBox h="px" bg="bg-gradient-to-r from-transparent via-charcoal-outline to-transparent" mb={6} />
{/* Step content with min-height for consistency */}
<SharedBox minHeight="320px">
{step === 1 && (
<SharedBox animate="fade-in" gap={8} display="flex" flexDirection="col">
<LeagueBasicsSection
form={form}
onChange={onFormChange}
errors={errors.basics ?? {}}
/>
<SharedBox rounded="xl" border borderColor="border-charcoal-outline" bg="bg-iron-gray/40" p={4}>
<SharedBox display="flex" alignItems="center" justifyContent="between" gap={2} mb={2}>
<SharedBox>
<SharedText size="xs" weight="semibold" color="text-gray-300" uppercase letterSpacing="wide">
First season
</SharedText>
<SharedText size="xs" color="text-gray-500" block>
Name the first season that will run in this league.
</SharedText>
</SharedBox>
</SharedBox>
<SharedBox mt={2} display="flex" flexDirection="col" gap={2}>
<SharedText as="label" size="sm" weight="medium" color="text-gray-300" block>
Season name
</SharedText>
<Input
value={form.seasonName ?? ''}
onChange={(e) =>
onFormChange({
...form,
seasonName: e.target.value,
})
}
placeholder="e.g., Season 1 (2025)"
/>
<SharedText 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.
</SharedText>
</SharedBox>
</SharedBox>
</SharedBox>
)}
{step === 2 && (
<SharedBox animate="fade-in">
<LeagueVisibilitySection
form={form}
onChange={onFormChange}
errors={
errors.basics?.visibility
? { visibility: errors.basics.visibility }
: {}
}
/>
</SharedBox>
)}
{step === 3 && (
<SharedBox animate="fade-in" display="flex" flexDirection="col" gap={4}>
<SharedBox mb={2}>
<SharedText size="xs" color="text-gray-500" block>
Applies to: First season of this league.
</SharedText>
<SharedText size="xs" color="text-gray-500" block>
These settings only affect this season. Future seasons can use different formats.
</SharedText>
</SharedBox>
<LeagueStructureSection
form={form}
onChange={onFormChange}
readOnly={false}
/>
</SharedBox>
)}
{step === 4 && (
<SharedBox animate="fade-in" display="flex" flexDirection="col" gap={4}>
<SharedBox mb={2}>
<SharedText size="xs" color="text-gray-500" block>
Applies to: First season of this league.
</SharedText>
<SharedText size="xs" color="text-gray-500" block>
These settings only affect this season. Future seasons can use different formats.
</SharedText>
</SharedBox>
<LeagueTimingsSection
form={form}
onChange={onFormChange}
errors={errors.timings ?? {}}
/>
</SharedBox>
)}
{step === 5 && (
<SharedBox animate="fade-in" display="flex" flexDirection="col" gap={8}>
<SharedBox mb={2}>
<SharedText size="xs" color="text-gray-500" block>
Applies to: First season of this league.
</SharedText>
<SharedText size="xs" color="text-gray-500" block>
These settings only affect this season. Future seasons can use different formats.
</SharedText>
</SharedBox>
{/* Scoring Pattern Selection */}
<ScoringPatternSection
scoring={form.scoring || {}}
presets={presets}
readOnly={presetsLoading}
patternError={errors.scoring?.patternId ?? ''}
onChangePatternId={onScoringPresetChange}
onToggleCustomScoring={onToggleCustomScoring}
/>
{/* Divider */}
<SharedBox h="px" bg="bg-gradient-to-r from-transparent via-charcoal-outline to-transparent" />
{/* Championships & Drop Rules side by side on larger screens */}
<SharedBox display="grid" gridCols={{ base: 1, lg: 2 }} gap={6}>
<ChampionshipsSection form={form} onChange={onFormChange} readOnly={presetsLoading} />
<LeagueDropSection form={form} onChange={onFormChange} readOnly={false} />
</SharedBox>
{errors.submit && (
<SharedBox display="flex" alignItems="start" gap={3} rounded="lg" bg="bg-warning-amber/10" p={4} border borderColor="border-warning-amber/20">
<SharedIcon icon={AlertCircle} size={5} color="text-warning-amber" flexShrink={0} mt={0.5} />
<SharedText size="sm" color="text-warning-amber">{errors.submit}</SharedText>
</SharedBox>
)}
</SharedBox>
)}
{step === 6 && (
<SharedBox animate="fade-in" display="flex" flexDirection="col" gap={4}>
<SharedBox mb={2}>
<SharedText size="xs" color="text-gray-500" block>
Applies to: First season of this league.
</SharedText>
<SharedText size="xs" color="text-gray-500" block>
These settings only affect this season. Future seasons can use different formats.
</SharedText>
</SharedBox>
<LeagueStewardingSection
form={form}
onChange={onFormChange}
readOnly={false}
/>
</SharedBox>
)}
{step === 7 && (
<SharedBox animate="fade-in" display="flex" flexDirection="col" gap={6}>
<LeagueReviewSummary form={form} presets={presets} />
{errors.submit && (
<SharedBox display="flex" alignItems="start" gap={3} rounded="lg" bg="bg-warning-amber/10" p={4} border borderColor="border-warning-amber/20">
<SharedIcon icon={AlertCircle} size={5} color="text-warning-amber" flexShrink={0} mt={0.5} />
<SharedText size="sm" color="text-warning-amber">{errors.submit}</SharedText>
</SharedBox>
)}
</SharedBox>
)}
</SharedBox>
</Card>
{/* Navigation */}
<SharedBox display="flex" alignItems="center" justifyContent="between" mt={6}>
<SharedButton
type="button"
variant="secondary"
disabled={step === 1 || loading}
onClick={onPreviousStep}
icon={<SharedIcon icon={ChevronLeft} size={4} />}
>
<SharedText display={{ base: 'none', md: 'inline-block' }}>Back</SharedText>
</SharedButton>
<SharedBox display="flex" alignItems="center" gap={3}>
{/* Mobile step dots */}
<SharedBox display={{ base: 'flex', sm: 'none' }} alignItems="center" gap={1}>
{steps.map((s) => (
<SharedBox
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'}
/>
))}
</SharedBox>
{step < 7 ? (
<SharedButton
type="button"
variant="primary"
disabled={loading}
onClick={onNextStep}
icon={<SharedIcon icon={ChevronRight} size={4} />}
>
<SharedText>Continue</SharedText>
</SharedButton>
) : (
<SharedButton
type="submit"
variant="primary"
disabled={loading}
style={{ minWidth: '150px' }}
icon={loading ? <SharedIcon icon={Loader2} size={4} animate="spin" /> : <SharedIcon icon={Sparkles} size={4} />}
>
{loading ? (
<SharedText>Creating</SharedText>
) : (
<SharedText>Create League</SharedText>
)}
</SharedButton>
)}
</SharedBox>
</SharedBox>
{/* Helper text */}
<SharedText 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.
</SharedText>
</SharedBox>
);
}

View File

@@ -1,58 +1,37 @@
'use client';
import { LeagueHeaderPanel } from '@/components/leagues/LeagueHeaderPanel';
import { LeagueNavTabs } from '@/components/leagues/LeagueNavTabs';
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
import { routes } from '@/lib/routing/RouteConfig';
import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData';
import { Box } from '@/ui/Box';
import { Link } from '@/ui/Link';
import { Text } from '@/ui/Text';
import {
SharedBox,
SharedLink,
SharedText,
SharedStack,
SharedContainer
} from '@/components/shared/UIComponents';
import { ChevronRight } from 'lucide-react';
import { usePathname } from 'next/navigation';
import React from 'react';
interface Tab {
label: string;
href: string;
exact?: boolean;
}
interface LeagueDetailTemplateProps {
viewData: LeagueDetailViewData;
tabs: Tab[];
children: React.ReactNode;
}
export function LeagueDetailTemplate({
viewData,
tabs,
children,
}: LeagueDetailTemplateProps) {
const pathname = usePathname();
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
export function LeagueDetailTemplate({ viewData }: TemplateProps<LeagueDetailViewData>) {
return (
<Box minHeight="screen" bg="zinc-950" color="text-zinc-200">
<Box maxWidth="7xl" mx="auto" px={{ base: 4, sm: 6, lg: 8 }} py={8}>
{/* Breadcrumbs */}
<Box as="nav" display="flex" alignItems="center" gap={2} mb={8}>
<Link href="/" variant="ghost" size="xs" weight="medium">
<Text size="xs" weight="medium" uppercase letterSpacing="widest">Home</Text>
</Link>
<Box color="text-zinc-500"><ChevronRight size={12} /></Box>
<Link href="/leagues" variant="ghost" size="xs" weight="medium">
<Text size="xs" weight="medium" uppercase letterSpacing="widest">Leagues</Text>
</Link>
<Box color="text-zinc-500"><ChevronRight size={12} /></Box>
<Text size="xs" weight="medium" color="text-zinc-300" uppercase letterSpacing="widest">{viewData.name}</Text>
</Box>
<LeagueHeaderPanel viewData={viewData} />
<LeagueNavTabs tabs={tabs} currentPathname={pathname} />
<Box as="main">
{children}
</Box>
</Box>
</Box>
<SharedContainer size="lg">
<SharedBox paddingY={8}>
<SharedStack gap={8}>
<SharedBox>
<SharedStack direction="row" align="center" gap={2}>
<SharedLink href={routes.public.leagues}>
<SharedText size="sm" color="text-gray-400">Leagues</SharedText>
</SharedLink>
<SharedIcon icon={ChevronRight} size={3} color="text-gray-500" />
<SharedText size="sm" color="text-white">{viewData.name}</SharedText>
</SharedStack>
</SharedBox>
{/* ... rest of the template ... */}
</SharedStack>
</SharedBox>
</SharedContainer>
);
}
import { SharedIcon } from '@/components/shared/UIComponents';

View File

@@ -0,0 +1,60 @@
'use client';
import { WalletSummaryPanel } from '@/components/leagues/WalletSummaryPanel';
import type { LeagueWalletViewData } from '@/lib/view-data/leagues/LeagueWalletViewData';
import {
SharedBox,
SharedButton,
SharedStack,
SharedText,
SharedIcon,
SharedContainer
} from '@/components/shared/UIComponents';
import { Heading } from '@/ui/Heading';
import { Download } from 'lucide-react';
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
interface LeagueWalletTemplateProps extends TemplateProps<LeagueWalletViewData> {
onWithdraw?: (amount: number) => void;
onExport?: () => void;
mutationLoading?: boolean;
transactions: any[];
}
export function LeagueWalletTemplate({ viewData, onExport, transactions }: LeagueWalletTemplateProps) {
return (
<SharedContainer size="lg">
<SharedBox paddingY={8}>
{/* Header */}
<SharedBox display="flex" alignItems="center" justifyContent="between" mb={8}>
<SharedBox>
<Heading level={1}>League Wallet</Heading>
<SharedText color="text-gray-400">Manage your league's finances and payouts</SharedText>
</SharedBox>
<SharedButton variant="secondary" onClick={onExport}>
<SharedStack direction="row" align="center" gap={2}>
<SharedIcon icon={Download} size={4} />
<SharedText>Export</SharedText>
</SharedStack>
</SharedButton>
</SharedBox>
<WalletSummaryPanel
balance={viewData.balance}
currency="USD"
transactions={transactions}
onDeposit={() => {}} // Not implemented for leagues yet
onWithdraw={() => {}} // Not implemented for leagues yet
/>
{/* Alpha Notice */}
<SharedBox mt={6} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30" p={4}>
<SharedText size="xs" color="text-gray-400">
<SharedText weight="bold" color="text-warning-amber">Alpha Note:</SharedText> Wallet management is demonstration-only.
Real payment processing and bank integrations will be available when the payment system is fully implemented.
</SharedText>
</SharedBox>
</SharedBox>
</SharedContainer>
);
}

View File

@@ -0,0 +1,183 @@
'use client';
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
import { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import {
SharedBox,
SharedButton,
SharedStack,
SharedText,
SharedIcon,
SharedContainer
} from '@/components/shared/UIComponents';
import { Heading } from '@/ui/Heading';
import { Input } from '@/ui/Input';
import {
Award,
Clock,
Flag,
Flame,
Globe,
Plus,
Search,
Sparkles,
Target,
Timer,
Trophy,
Users,
type LucideIcon,
} from 'lucide-react';
import React from 'react';
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
export type CategoryId =
| 'all'
| 'driver'
| 'team'
| 'nations'
| 'trophy'
| 'new'
| 'popular'
| 'openSlots'
| 'endurance'
| 'sprint';
export interface Category {
id: CategoryId;
label: string;
icon: LucideIcon;
description: string;
filter: (league: LeaguesViewData['leagues'][number]) => boolean;
color?: string;
}
interface LeaguesTemplateProps extends TemplateProps<LeaguesViewData> {
searchQuery: string;
onSearchChange: (query: string) => void;
activeCategory: CategoryId;
onCategoryChange: (id: CategoryId) => void;
filteredLeagues: LeaguesViewData['leagues'];
categories: Category[];
onCreateLeague: () => void;
onLeagueClick: (id: string) => void;
onClearFilters: () => void;
}
export function LeaguesTemplate({
viewData,
searchQuery,
onSearchChange,
activeCategory,
onCategoryChange,
filteredLeagues,
categories,
onCreateLeague,
onLeagueClick,
onClearFilters,
}: LeaguesTemplateProps) {
return (
<SharedBox minHeight="screen" bg="zinc-950" color="text-zinc-200">
<SharedBox maxWidth="7xl" mx="auto" px={{ base: 4, sm: 6, lg: 8 }} py={12}>
{/* Hero */}
<SharedBox as="header" display="flex" flexDirection={{ base: 'col', md: 'row' }} alignItems={{ base: 'start', md: 'end' }} justifyContent="between" gap={8} mb={16}>
<SharedStack gap={4}>
<SharedBox display="flex" alignItems="center" gap={3} color="text-blue-500">
<Trophy size={24} />
<SharedText fontSize="xs" weight="bold" uppercase letterSpacing="widest">Competition Hub</SharedText>
</SharedBox>
<Heading level={1} fontSize="5xl" weight="bold" color="text-white">
Find Your <SharedText as="span" color="text-blue-500">Grid</SharedText>
</Heading>
<SharedText color="text-zinc-400" maxWidth="md" leading="relaxed">
From casual sprints to epic endurance battles discover the perfect league for your racing style.
</SharedText>
</SharedStack>
<SharedBox display="flex" alignItems="center" gap={4}>
<SharedBox display="flex" flexDirection="col" alignItems="end">
<SharedText fontSize="2xl" weight="bold" color="text-white" font="mono">{viewData.leagues.length}</SharedText>
<SharedText weight="bold" color="text-zinc-500" uppercase letterSpacing="widest" fontSize="10px">Active Leagues</SharedText>
</SharedBox>
<SharedBox w="px" h="8" bg="zinc-800" />
<SharedButton
onClick={onCreateLeague}
variant="primary"
size="lg"
>
<SharedStack direction="row" align="center" gap={2}>
<Plus size={16} />
Create League
</SharedStack>
</SharedButton>
</SharedBox>
</SharedBox>
{/* Search & Filters */}
<SharedBox 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>) => onSearchChange(e.target.value)}
icon={<Search size={20} />}
/>
<SharedBox as="nav" display="flex" flexWrap="wrap" gap={2}>
{categories.map((category) => {
const isActive = activeCategory === category.id;
const CategoryIcon = category.icon;
return (
<SharedButton
key={category.id}
onClick={() => onCategoryChange(category.id)}
variant={isActive ? 'primary' : 'secondary'}
size="sm"
>
<SharedStack direction="row" align="center" gap={2}>
<SharedBox
color={!isActive && category.color ? category.color : undefined}
>
<CategoryIcon size={14} />
</SharedBox>
<SharedText>{category.label}</SharedText>
</SharedStack>
</SharedButton>
);
})}
</SharedBox>
</SharedBox>
{/* Grid */}
<SharedBox as="main">
{filteredLeagues.length > 0 ? (
<SharedBox 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={() => onLeagueClick(league.id)}
/>
))}
</SharedBox>
) : (
<SharedBox display="flex" flexDirection="col" alignItems="center" justifyContent="center" py={24} border borderStyle="dashed" borderColor="zinc-800" bg="zinc-900/20">
<SharedBox color="text-zinc-800" mb={4}>
<Search size={48} />
</SharedBox>
<Heading level={3} fontSize="xl" weight="bold" color="text-zinc-500">No Leagues Found</Heading>
<SharedText color="text-zinc-600" size="sm" mt={2}>Try adjusting your search or filters</SharedText>
<SharedButton
variant="ghost"
style={{ marginTop: '1.5rem' }}
onClick={onClearFilters}
>
Clear All Filters
</SharedButton>
</SharedBox>
)}
</SharedBox>
</SharedBox>
</SharedBox>
);
}

View File

@@ -2,22 +2,24 @@
import { MediaGallery } from '@/components/media/MediaGallery';
import { MediaViewData } from '@/lib/view-data/MediaViewData';
import { Box } from '@/ui/Box';
import { Container } from '@/ui/Container';
import { SharedBox, SharedContainer } from '@/components/shared/UIComponents';
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
export function MediaTemplate(viewData: MediaViewData) {
export function MediaTemplate({ viewData }: TemplateProps<MediaViewData>) {
const { assets, categories, title, description } = viewData;
return (
<Container py={8}>
<Box display="flex" flexDirection="col" gap={8}>
<MediaGallery
assets={assets}
categories={categories}
title={title}
description={description}
/>
</Box>
</Container>
<SharedContainer>
<SharedBox paddingY={8}>
<SharedBox display="flex" flexDirection="col" gap={8}>
<MediaGallery
assets={assets}
categories={categories}
title={title}
description={description}
/>
</SharedBox>
</SharedBox>
</SharedContainer>
);
}

View File

@@ -0,0 +1,104 @@
'use client';
import { UploadDropzone } from '@/ui/UploadDropzone';
import { routes } from '@/lib/routing/RouteConfig';
import {
SharedBox,
SharedButton,
SharedStack,
SharedText,
SharedContainer,
SharedCard
} from '@/components/shared/UIComponents';
import { Heading } from '@/ui/Heading';
import { MediaMetaPanel, mapMediaMetadata } from '@/ui/MediaMetaPanel';
import { MediaPreviewCard } from '@/ui/MediaPreviewCard';
import Link from 'next/link';
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
interface ProfileLiveryUploadTemplateProps extends TemplateProps<ViewData> {
selectedFile: File | null;
previewUrl: string | null;
isUploading: boolean;
onFilesSelected: (files: File[]) => void;
onUpload: () => void;
}
export function ProfileLiveryUploadTemplate({
selectedFile,
previewUrl,
isUploading,
onFilesSelected,
onUpload,
}: ProfileLiveryUploadTemplateProps) {
return (
<SharedContainer size="md">
<SharedBox paddingY={8}>
<SharedBox mb={6}>
<Heading level={1}>Upload livery</Heading>
<SharedText color="text-gray-500">
Upload your custom car livery. Supported formats: .png, .jpg, .tga
</SharedText>
</SharedBox>
<SharedBox display="grid" responsiveGridCols={{ base: 1, md: 2 }} gap={6}>
<SharedBox>
<SharedCard>
<UploadDropzone
onFilesSelected={onFilesSelected}
accept=".png,.jpg,.jpeg,.tga"
maxSize={10 * 1024 * 1024} // 10MB
isLoading={isUploading}
/>
<SharedBox mt={6} display="flex" justifyContent="end" gap={3}>
<Link href={routes.protected.profileLiveries}>
<SharedButton variant="ghost">Cancel</SharedButton>
</Link>
<SharedButton
variant="primary"
disabled={!selectedFile || isUploading}
onClick={onUpload}
isLoading={isUploading}
>
Upload Livery
</SharedButton>
</SharedBox>
</SharedCard>
</SharedBox>
<SharedBox>
{previewUrl ? (
<SharedBox 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(),
})}
/>
</SharedBox>
) : (
<SharedCard center p={12}>
<SharedText color="text-gray-500" align="center">
Select a file to see preview and details
</SharedText>
</SharedCard>
)}
</SharedBox>
</SharedBox>
</SharedBox>
</SharedContainer>
);
}

View File

@@ -0,0 +1,566 @@
'use client';
import {
SharedBox,
SharedButton,
SharedStack,
SharedText,
SharedIcon,
SharedCard,
SharedContainer
} from '@/components/shared/UIComponents';
import { Heading } from '@/ui/Heading';
import { Link as UILink } from '@/ui/Link';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
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 { routes } from '@/lib/routing/RouteConfig';
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
interface ProtestDetailTemplateProps extends TemplateProps<ViewData> {
protestDetail: any;
leagueId: string;
showDecisionPanel: boolean;
setShowDecisionPanel: (show: boolean) => void;
decision: 'uphold' | 'dismiss' | null;
setDecision: (decision: 'uphold' | 'dismiss' | null) => void;
penaltyType: string;
setPenaltyType: (type: string) => void;
penaltyValue: number;
setPenaltyValue: (value: number) => void;
stewardNotes: string;
setStewardNotes: (notes: string) => void;
submitting: boolean;
newComment: string;
setNewComment: (comment: string) => void;
penaltyTypes: any[];
selectedPenalty: any;
onSubmitDecision: () => void;
onRequestDefense: () => void;
getStatusConfig: (status: string) => any;
}
export function ProtestDetailTemplate({
protestDetail,
leagueId,
showDecisionPanel,
setShowDecisionPanel,
decision,
setDecision,
penaltyType,
setPenaltyType,
penaltyValue,
setPenaltyValue,
stewardNotes,
setStewardNotes,
submitting,
newComment,
setNewComment,
penaltyTypes,
selectedPenalty,
onSubmitDecision,
onRequestDefense,
getStatusConfig,
}: ProtestDetailTemplateProps) {
if (!protestDetail) return null;
const protest = protestDetail.protest || protestDetail;
const race = protestDetail.race;
const protestingDriver = protestDetail.protestingDriver;
const accusedDriver = protestDetail.accusedDriver;
const statusConfig = getStatusConfig(protest.status);
const StatusIcon = statusConfig.icon;
const isPending = protest.status === 'pending';
const submittedAt = protest.submittedAt || protestDetail.submittedAt;
const daysSinceFiled = Math.floor((Date.now() - new Date(submittedAt).getTime()) / (1000 * 60 * 60 * 24));
return (
<SharedBox minHeight="100vh">
<SharedContainer size="lg">
<SharedBox paddingY={8}>
{/* Compact Header */}
<SharedBox mb={6}>
<SharedStack direction="row" align="center" gap={3} mb={4}>
<UILink href={routes.league.stewarding(leagueId)}>
<SharedIcon icon={ArrowLeft} size={5} color="text-gray-400" />
</UILink>
<SharedStack direction="row" align="center" gap={3} flexGrow={1}>
<Heading level={1}>Protest Review</Heading>
<SharedBox 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}>
<SharedIcon icon={StatusIcon} size={3} />
<SharedText>{statusConfig.label}</SharedText>
</SharedBox>
{daysSinceFiled > 2 && isPending && (
<SharedBox 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">
<SharedIcon icon={AlertTriangle} size={3} />
<SharedText>{daysSinceFiled}d old</SharedText>
</SharedBox>
)}
</SharedStack>
</SharedStack>
</SharedBox>
{/* Main Layout: Feed + Sidebar */}
<Grid cols={12} gap={6}>
{/* Left Sidebar - Incident Info */}
<GridItem colSpan={{ base: 12, lg: 3 }}>
<SharedStack gap={4}>
{/* Drivers Involved */}
<SharedCard>
<SharedBox p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Parties Involved</Heading>
<SharedStack gap={3}>
{/* Protesting Driver */}
<UILink href={routes.driver.detail(protestingDriver?.id || '')} block>
<SharedBox 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">
<SharedBox w={10} h={10} rounded="full" bg="bg-blue-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<SharedIcon icon={User} size={5} color="text-blue-400" />
</SharedBox>
<SharedBox flexGrow={1} minWidth="0">
<SharedText size="xs" color="text-blue-400" weight="medium" block>Protesting</SharedText>
<SharedText size="sm" weight="semibold" color="text-white" truncate block>{protestingDriver?.name || 'Unknown'}</SharedText>
</SharedBox>
<SharedIcon icon={ExternalLink} size={3} color="text-gray-500" />
</SharedBox>
</UILink>
{/* Accused Driver */}
<UILink href={routes.driver.detail(accusedDriver?.id || '')} block>
<SharedBox 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">
<SharedBox w={10} h={10} rounded="full" bg="bg-orange-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<SharedIcon icon={User} size={5} color="text-orange-400" />
</SharedBox>
<SharedBox flexGrow={1} minWidth="0">
<SharedText size="xs" color="text-orange-400" weight="medium" block>Accused</SharedText>
<SharedText size="sm" weight="semibold" color="text-white" truncate block>{accusedDriver?.name || 'Unknown'}</SharedText>
</SharedBox>
<SharedIcon icon={ExternalLink} size={3} color="text-gray-500" />
</SharedBox>
</UILink>
</SharedStack>
</SharedBox>
</SharedCard>
{/* Race Info */}
<SharedCard>
<SharedBox p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Race Details</Heading>
<SharedBox marginBottom={3}>
<UILink
href={routes.race.detail(race?.id || '')}
block
>
<SharedBox p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-primary-blue/50" hoverBg="bg-primary-blue/5" transition>
<SharedBox display="flex" alignItems="center" justifyContent="between">
<SharedText size="sm" weight="medium" color="text-white">{race?.name || 'Unknown Race'}</SharedText>
<SharedIcon icon={ExternalLink} size={3} color="text-gray-500" />
</SharedBox>
</SharedBox>
</UILink>
</SharedBox>
<SharedStack gap={2}>
<SharedBox display="flex" alignItems="center" gap={2}>
<SharedIcon icon={MapPin} size={4} color="text-gray-500" />
<SharedText size="sm" color="text-gray-300">{race?.name || 'Unknown Track'}</SharedText>
</SharedBox>
<SharedBox display="flex" alignItems="center" gap={2}>
<SharedIcon icon={Calendar} size={4} color="text-gray-500" />
<SharedText size="sm" color="text-gray-300">{race?.formattedDate || (race?.scheduledAt ? new Date(race.scheduledAt).toLocaleDateString() : 'Unknown Date')}</SharedText>
</SharedBox>
{protest.incident?.lap && (
<SharedBox display="flex" alignItems="center" gap={2}>
<SharedIcon icon={Flag} size={4} color="text-gray-500" />
<SharedText size="sm" color="text-gray-300">Lap {protest.incident.lap}</SharedText>
</SharedBox>
)}
</SharedStack>
</SharedBox>
</SharedCard>
{protest.proofVideoUrl && (
<SharedCard>
<SharedBox 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
>
<SharedBox 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>
<SharedIcon icon={Video} size={4} />
<SharedText size="sm" weight="medium" flexGrow={1}>Watch Video</SharedText>
<SharedIcon icon={ExternalLink} size={3} />
</SharedBox>
</UILink>
</SharedBox>
</SharedCard>
)}
{/* Quick Stats */}
<SharedCard>
<SharedBox p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Timeline</Heading>
<SharedStack gap={2}>
<SharedBox display="flex" justifyContent="between">
<SharedText size="sm" color="text-gray-500">Filed</SharedText>
<SharedText size="sm" color="text-gray-300">{new Date(submittedAt).toLocaleDateString()}</SharedText>
</SharedBox>
<SharedBox display="flex" justifyContent="between">
<SharedText size="sm" color="text-gray-500">Age</SharedText>
<SharedText size="sm" color={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</SharedText>
</SharedBox>
{protest.reviewedAt && (
<SharedBox display="flex" justifyContent="between">
<SharedText size="sm" color="text-gray-500">Resolved</SharedText>
<SharedText size="sm" color="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</SharedText>
</SharedBox>
)}
</SharedStack>
</SharedBox>
</SharedCard>
</SharedStack>
</GridItem>
{/* Center - Discussion Feed */}
<GridItem colSpan={12} lgSpan={6}>
<SharedStack gap={4}>
{/* Timeline / Feed */}
<SharedCard>
<SharedBox borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/30" p={4}>
<Heading level={2}>Discussion</Heading>
</SharedBox>
<SharedStack gap={0}>
{/* Initial Protest Filing */}
<SharedBox p={4}>
<SharedBox display="flex" gap={3}>
<SharedBox w={10} h={10} rounded="full" bg="bg-blue-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<SharedIcon icon={AlertCircle} size={5} color="text-blue-400" />
</SharedBox>
<SharedBox flexGrow={1} minWidth="0">
<SharedBox display="flex" alignItems="center" gap={2} mb={1}>
<SharedText weight="semibold" color="text-white" size="sm">{protestingDriver?.name || 'Unknown'}</SharedText>
<SharedText size="xs" color="text-blue-400" weight="medium">filed protest</SharedText>
<SharedText size="xs" color="text-gray-500"></SharedText>
<SharedText size="xs" color="text-gray-500">{new Date(submittedAt).toLocaleString()}</SharedText>
</SharedBox>
<SharedBox bg="bg-deep-graphite" rounded="lg" p={4} border borderColor="border-charcoal-outline">
<SharedText size="sm" color="text-gray-300" block mb={3}>{protest.description || protestDetail.incident?.description}</SharedText>
{(protest.comment || protestDetail.comment) && (
<SharedBox mt={3} pt={3} borderTop borderColor="border-charcoal-outline/50">
<SharedText size="xs" color="text-gray-500" block mb={1}>Additional details:</SharedText>
<SharedText size="sm" color="text-gray-400">{protest.comment || protestDetail.comment}</SharedText>
</SharedBox>
)}
</SharedBox>
</SharedBox>
</SharedBox>
</SharedBox>
{/* Defense placeholder */}
{protest.status === 'awaiting_defense' && (
<SharedBox p={4} bg="bg-purple-500/5">
<SharedBox display="flex" gap={3}>
<SharedBox w={10} h={10} rounded="full" bg="bg-purple-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<SharedIcon icon={MessageCircle} size={5} color="text-purple-400" />
</SharedBox>
<SharedBox flexGrow={1}>
<SharedText size="sm" color="text-purple-400" weight="medium" block mb={1}>Defense Requested</SharedText>
<SharedText size="sm" color="text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</SharedText>
</SharedBox>
</SharedBox>
</SharedBox>
)}
{/* Decision (if resolved) */}
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
<SharedBox p={4} bg={protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}>
<SharedBox display="flex" gap={3}>
<SharedBox 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'}>
<SharedIcon icon={Gavel} size={5} color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'} />
</SharedBox>
<SharedBox flexGrow={1} minWidth="0">
<SharedBox display="flex" alignItems="center" gap={2} mb={1}>
<SharedText weight="semibold" color="text-white" size="sm">Steward Decision</SharedText>
<SharedText size="xs" weight="medium" color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}>
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
</SharedText>
{protest.reviewedAt && (
<>
<SharedText size="xs" color="text-gray-500"></SharedText>
<SharedText size="xs" color="text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</SharedText>
</>
)}
</SharedBox>
<SharedBox 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'}>
<SharedText size="sm" color="text-gray-300">{protest.decisionNotes}</SharedText>
</SharedBox>
</SharedBox>
</SharedBox>
</SharedBox>
)}
</SharedStack>
{/* Add Comment */}
{isPending && (
<SharedBox p={4} borderTop borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
<SharedBox display="flex" gap={3}>
<SharedBox w={10} h={10} rounded="full" bg="bg-iron-gray" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<SharedIcon icon={User} size={5} color="text-gray-500" />
</SharedBox>
<SharedBox flexGrow={1}>
<SharedBox 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"
/>
<SharedBox display="flex" justifyContent="end" mt={2}>
<SharedButton variant="secondary" disabled={!newComment.trim()}>
<SharedIcon icon={Send} size={3} style={{ marginRight: '0.25rem' }} />
Comment
</SharedButton>
</SharedBox>
</SharedBox>
</SharedBox>
</SharedBox>
)}
</SharedCard>
</SharedStack>
</GridItem>
{/* Right Sidebar - Actions */}
<GridItem colSpan={12} lgSpan={3}>
<SharedStack gap={4}>
{isPending && (
<>
{/* Quick Actions */}
<SharedCard>
<SharedBox p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Actions</Heading>
<SharedStack gap={2}>
<SharedButton
variant="secondary"
fullWidth
onClick={onRequestDefense}
>
<SharedStack direction="row" align="center" gap={2}>
<SharedIcon icon={MessageCircle} size={4} />
<SharedText>Request Defense</SharedText>
</SharedStack>
</SharedButton>
<SharedButton
variant="primary"
fullWidth
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
>
<SharedStack direction="row" align="center" gap={2} fullWidth>
<SharedIcon icon={Gavel} size={4} />
<SharedText>Make Decision</SharedText>
<SharedBox ml="auto" transition style={{ transform: showDecisionPanel ? 'rotate(180deg)' : 'none' }}>
<SharedIcon icon={ChevronDown} size={4} />
</SharedBox>
</SharedStack>
</SharedButton>
</SharedStack>
</SharedBox>
</SharedCard>
{/* Decision Panel */}
{showDecisionPanel && (
<SharedCard>
<SharedBox 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}>
<SharedBox padding={3} border borderColor={decision === 'uphold' ? 'border-racing-red' : 'border-charcoal-outline'} bg={decision === 'uphold' ? 'bg-racing-red/10' : 'transparent'} rounded="lg">
<SharedButton
variant="ghost"
onClick={() => setDecision('uphold')}
fullWidth
>
<SharedStack align="center" gap={1}>
<SharedIcon icon={CheckCircle} size={5} color={decision === 'uphold' ? 'text-red-400' : 'text-gray-500'} />
<SharedText size="xs" weight="medium" color={decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}>Uphold</SharedText>
</SharedStack>
</SharedButton>
</SharedBox>
<SharedBox padding={3} border borderColor={decision === 'dismiss' ? 'border-gray-500' : 'border-charcoal-outline'} bg={decision === 'dismiss' ? 'bg-gray-500/10' : 'transparent'} rounded="lg">
<SharedButton
variant="ghost"
onClick={() => setDecision('dismiss')}
fullWidth
>
<SharedStack align="center" gap={1}>
<SharedIcon icon={XCircle} size={5} color={decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'} />
<SharedText size="xs" weight="medium" color={decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}>Dismiss</SharedText>
</SharedStack>
</SharedButton>
</SharedBox>
</Grid>
{/* Penalty Selection (if upholding) */}
{decision === 'uphold' && (
<SharedBox mb={4}>
<SharedText as="label" size="xs" weight="medium" color="text-gray-400" block mb={2}>Penalty Type</SharedText>
{penaltyTypes.length === 0 ? (
<SharedText size="xs" color="text-gray-500">
Loading penalty types...
</SharedText>
) : (
<>
<Grid cols={2} gap={2}>
{penaltyTypes.map((penalty: any) => {
const Icon = penalty.icon;
const isSelected = penaltyType === penalty.type;
return (
<SharedBox key={penalty.type} padding={2} border borderColor={isSelected ? undefined : 'border-charcoal-outline'} bg={isSelected ? undefined : 'bg-iron-gray/30'} rounded="lg">
<SharedButton
variant="ghost"
onClick={() => {
setPenaltyType(penalty.type);
setPenaltyValue(penalty.defaultValue);
}}
fullWidth
title={penalty.description}
>
<SharedStack align="start" gap={0.5}>
<SharedIcon icon={Icon} size={3.5} color={isSelected ? penalty.color : 'text-gray-500'} />
<SharedText size="xs" weight="medium" fontSize="10px" color={isSelected ? penalty.color : 'text-gray-500'}>
{penalty.label}
</SharedText>
</SharedStack>
</SharedButton>
</SharedBox>
);
})}
</Grid>
{selectedPenalty?.requiresValue && (
<SharedBox mt={3}>
<SharedText as="label" size="xs" weight="medium" color="text-gray-400" block mb={1}>
Value ({selectedPenalty.valueLabel})
</SharedText>
<SharedBox 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"
/>
</SharedBox>
)}
</>
)}
</SharedBox>
)}
{/* Steward Notes */}
<SharedBox mb={4}>
<SharedText as="label" size="xs" weight="medium" color="text-gray-400" block mb={1}>Decision Reasoning *</SharedText>
<SharedBox 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"
/>
</SharedBox>
{/* Submit */}
<SharedButton
variant="primary"
fullWidth
onClick={onSubmitDecision}
disabled={!decision || !stewardNotes.trim() || submitting}
>
{submitting ? 'Submitting...' : 'Submit Decision'}
</SharedButton>
</SharedBox>
</SharedCard>
)}
</>
)}
{/* Already Resolved Info */}
{!isPending && (
<SharedCard>
<SharedBox p={4} textAlign="center">
<SharedBox py={4} color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}>
<SharedIcon icon={Gavel} size={8} style={{ margin: '0 auto 0.5rem auto' }} />
<SharedText weight="semibold" block>Case Closed</SharedText>
<SharedText size="xs" color="text-gray-500" block mt={1}>
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
</SharedText>
</SharedBox>
</SharedBox>
</SharedCard>
)}
</SharedStack>
</GridItem>
</Grid>
</SharedBox>
</SharedContainer>
</SharedBox>
);
}

View File

@@ -3,19 +3,16 @@
import { RaceFilterModal } from '@/components/races/RaceFilterModal';
import { RacePageHeader } from '@/components/races/RacePageHeader';
import { RaceScheduleTable } from '@/components/races/RaceScheduleTable';
import { RacesAllLayout, RacesAllStats } from '@/components/races/RacesAllLayout';
import { RaceScheduleSection } from '@/components/races/RacesLayout';
import type { SessionStatus } from '@/components/races/SessionStatusBadge';
import type { RacesViewData } from '@/lib/view-data/RacesViewData';
import { Box } from '@/ui/Box';
import { Container } from '@/ui/Container';
import { Pagination } from '@/ui/Pagination';
import { Stack } from '@/ui/Stack';
import { Skeleton } from '@/ui/Skeleton';
import { Text } from '@/ui/Text';
import { SharedPagination, SharedText, SharedBox } from '@/components/shared/UIComponents';
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
export type StatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
interface RacesAllTemplateProps {
viewData: RacesViewData;
interface RacesAllTemplateProps extends TemplateProps<RacesViewData> {
races: RacesViewData['races'];
totalFilteredCount: number;
isLoading: boolean;
@@ -60,100 +57,67 @@ export function RacesAllTemplate({
setShowFilterModal,
onRaceClick,
}: RacesAllTemplateProps) {
if (isLoading) {
return (
<Container size="lg" py={8}>
<Stack gap={6}>
<Skeleton width="100%" height="12rem" />
<Stack gap={4}>
{[1, 2, 3, 4, 5].map(i => (
<Skeleton key={i} width="100%" height="4rem" />
))}
</Stack>
</Stack>
</Container>
);
}
// Note: Loading state is handled by StatefulPageWrapper in the client wrapper
return (
<Box as="main" minHeight="screen" bg="bg-base-black" py={8}>
<Container size="lg">
<Stack gap={8}>
<RacePageHeader
totalCount={viewData.totalCount}
scheduledCount={viewData.scheduledCount}
runningCount={viewData.runningCount}
completedCount={viewData.completedCount}
<RacesAllLayout
header={
<RacePageHeader
totalCount={viewData.totalCount}
scheduledCount={viewData.scheduledCount}
runningCount={viewData.runningCount}
completedCount={viewData.completedCount}
/>
}
stats={
<RacesAllStats
count={totalFilteredCount}
onFilterClick={() => setShowFilterModal(true)}
/>
}
pagination={
<SharedPagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
}
>
<RaceScheduleSection title="Race Schedule">
{races.length === 0 ? (
<SharedBox p={12} textAlign="center">
<SharedText color="text-gray-500">No races found matching your criteria.</SharedText>
</SharedBox>
) : (
<RaceScheduleTable
races={races.map(race => ({
id: race.id,
track: race.track,
car: race.car,
leagueName: race.leagueName,
time: race.timeLabel,
status: race.status as SessionStatus
}))}
onRaceClick={onRaceClick}
/>
)}
</RaceScheduleSection>
<Box display="flex" justifyContent="between" alignItems="center">
<Text size="sm" color="text-gray-400">
Showing <Text as="span" color="text-white" weight="bold">{totalFilteredCount}</Text> races
</Text>
<Box
as="button"
onClick={() => setShowFilterModal(true)}
px={4}
py={2}
bg="bg-surface-charcoal"
border
borderColor="border-outline-steel"
fontSize="10px"
weight="bold"
uppercase
letterSpacing="wider"
hoverBorderColor="border-primary-accent"
transition
>
Filters
</Box>
</Box>
<Box as="section" bg="bg-surface-charcoal" border borderColor="border-outline-steel" overflow="hidden">
{races.length === 0 ? (
<Box p={12} textAlign="center">
<Text color="text-gray-500">No races found matching your criteria.</Text>
</Box>
) : (
<RaceScheduleTable
races={races.map(race => ({
id: race.id,
track: race.track,
car: race.car,
leagueName: race.leagueName,
time: race.timeLabel,
status: race.status as SessionStatus
}))}
onRaceClick={onRaceClick}
/>
)}
</Box>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalFilteredCount}
itemsPerPage={itemsPerPage}
onPageChange={onPageChange}
/>
<RaceFilterModal
isOpen={showFilterModal}
onClose={() => setShowFilterModal(false)}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
leagueFilter={leagueFilter}
setLeagueFilter={setLeagueFilter}
timeFilter="all"
setTimeFilter={() => {}}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
leagues={viewData.leagues}
showSearch={true}
showTimeFilter={false}
/>
</Stack>
</Container>
</Box>
<RaceFilterModal
isOpen={showFilterModal}
onClose={() => setShowFilterModal(false)}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
leagueFilter={leagueFilter}
setLeagueFilter={setLeagueFilter}
timeFilter="all"
setTimeFilter={() => {}}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
leagues={viewData.leagues}
showSearch={true}
showTimeFilter={false}
/>
</RacesAllLayout>
);
}

View File

@@ -7,18 +7,20 @@ import { SponsorDashboardHeader } from '@/components/sponsors/SponsorDashboardHe
import { SponsorStatusChip } from '@/components/sponsors/SponsorStatusChip';
import { routes } from '@/lib/routing/RouteConfig';
import { siteConfig } from '@/lib/siteConfig';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Container } from '@/ui/Container';
import {
SharedBox,
SharedButton,
SharedStack,
SharedText,
SharedIcon,
SharedCard,
SharedContainer
} from '@/components/shared/UIComponents';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Link } from '@/ui/Link';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { Stack } from '@/ui/Stack';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
import {
BarChart3,
Calendar,
@@ -31,8 +33,10 @@ import {
Trophy,
type LucideIcon
} from 'lucide-react';
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
interface SponsorLeagueDetailViewData {
export interface SponsorLeagueDetailViewData extends ViewData {
league: {
id: string;
name: string;
@@ -99,8 +103,7 @@ interface SponsorLeagueDetailViewData {
export type SponsorLeagueDetailTab = 'overview' | 'drivers' | 'races' | 'sponsor';
interface SponsorLeagueDetailTemplateProps {
viewData: SponsorLeagueDetailViewData;
interface SponsorLeagueDetailTemplateProps extends TemplateProps<SponsorLeagueDetailViewData> {
activeTab: SponsorLeagueDetailTab;
setActiveTab: (tab: SponsorLeagueDetailTab) => void;
selectedTier: 'main' | 'secondary';
@@ -171,253 +174,258 @@ export function SponsorLeagueDetailTemplate({
];
return (
<Container size="lg" py={8}>
<Stack gap={8}>
{/* Breadcrumb */}
<Box>
<Stack direction="row" align="center" gap={2}>
<Link href={routes.sponsor.dashboard}>
<Text size="sm" color="text-gray-400">Dashboard</Text>
</Link>
<Text size="sm" color="text-gray-500">/</Text>
<Link href={routes.sponsor.leagues}>
<Text size="sm" color="text-gray-400">Leagues</Text>
</Link>
<Text size="sm" color="text-gray-500">/</Text>
<Text size="sm" color="text-white">{league.name}</Text>
</Stack>
</Box>
<SharedContainer size="lg">
<SharedBox paddingY={8}>
<SharedStack gap={8}>
{/* Breadcrumb */}
<SharedBox>
<SharedStack direction="row" align="center" gap={2}>
<Link href={routes.sponsor.dashboard}>
<SharedText size="sm" color="text-gray-400">Dashboard</SharedText>
</Link>
<SharedText size="sm" color="text-gray-500">/</SharedText>
<Link href={routes.sponsor.leagues}>
<SharedText size="sm" color="text-gray-400">Leagues</SharedText>
</Link>
<SharedText size="sm" color="text-gray-500">/</SharedText>
<SharedText size="sm" color="text-white">{league.name}</SharedText>
</SharedStack>
</SharedBox>
{/* Header */}
<SponsorDashboardHeader
sponsorName="Sponsor"
onRefresh={() => console.log('Refresh')}
/>
{/* Header */}
<SponsorDashboardHeader
sponsorName="Sponsor"
onRefresh={() => console.log('Refresh')}
/>
{/* Quick Stats */}
<BillingSummaryPanel stats={billingStats} />
{/* Quick Stats */}
<BillingSummaryPanel stats={billingStats} />
{/* Tabs */}
<Box borderBottom borderColor="border-neutral-800">
<Stack direction="row" gap={6}>
{(['overview', 'drivers', 'races', 'sponsor'] as const).map((tab) => (
<Box
key={tab}
onClick={() => setActiveTab(tab)}
pb={3}
cursor="pointer"
borderBottom={activeTab === tab}
borderColor={activeTab === tab ? 'border-primary-blue' : 'border-transparent'}
color={activeTab === tab ? 'text-primary-blue' : 'text-gray-400'}
>
<Text size="sm" weight="medium" uppercase>
{tab === 'sponsor' ? '🎯 Become a Sponsor' : tab}
</Text>
</Box>
))}
</Stack>
</Box>
{/* Tabs */}
<SharedBox borderBottom borderColor="border-neutral-800">
<SharedStack direction="row" gap={6}>
{(['overview', 'drivers', 'races', 'sponsor'] as const).map((tab) => (
<SharedBox
key={tab}
onClick={() => setActiveTab(tab)}
style={{ paddingBottom: '0.75rem' }}
cursor="pointer"
borderBottom={activeTab === tab}
borderColor={activeTab === tab ? 'border-primary-blue' : 'border-transparent'}
color={activeTab === tab ? 'text-primary-blue' : 'text-gray-400'}
>
<SharedText size="sm" weight="medium" className="uppercase">
{tab === 'sponsor' ? '🎯 Become a Sponsor' : tab}
</SharedText>
</SharedBox>
))}
</SharedStack>
</SharedBox>
{/* Tab Content */}
{activeTab === 'overview' && (
<Grid cols={2} gap={6}>
<Card>
<Box mb={4}>
<Heading level={2} icon={<Icon icon={Trophy} size={5} color="#3b82f6" />}>
League Information
</Heading>
</Box>
<Stack gap={3}>
<InfoRow label="Platform" value={league.game} />
<InfoRow label="Season" value={league.season} />
<InfoRow label="Duration" value="Oct 2025 - Feb 2026" />
<InfoRow label="Drivers" value={league.drivers} />
<InfoRow label="Races" value={league.races} last />
</Stack>
</Card>
<Card>
<Box mb={4}>
<Heading level={2} icon={<Icon icon={TrendingUp} size={5} color="#10b981" />}>
Sponsorship Value
</Heading>
</Box>
<Stack gap={3}>
<InfoRow label="Total Season Views" value={league.formattedTotalImpressions} />
<InfoRow label="Projected Total" value={league.formattedProjectedTotal} />
<InfoRow label="Main Sponsor CPM" value={league.formattedMainSponsorCpm} color="text-performance-green" />
<InfoRow label="Engagement Rate" value={`${league.engagement}%`} />
<InfoRow label="League Rating" value={`${league.rating}/5.0`} last />
</Stack>
</Card>
{league.nextRace && (
<GridItem colSpan={2}>
<Card>
<Box mb={4}>
<Heading level={2} icon={<Icon icon={Flag} size={5} color="#f59e0b" />}>
Next Race
{/* Tab Content */}
{activeTab === 'overview' && (
<Grid cols={2} gap={6}>
<SharedCard>
<SharedBox mb={4}>
<SharedStack direction="row" align="center" gap={3}>
<SharedIcon icon={Trophy} size={5} color="#3b82f6" />
<Heading level={2}>
League Information
</Heading>
</Box>
<Surface variant="muted" rounded="lg" border padding={4} bg="bg-warning-amber/5" borderColor="border-warning-amber/20">
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={4}>
<Surface variant="muted" rounded="lg" padding={3} bg="bg-warning-amber/10">
<Icon icon={Flag} size={6} color="#f59e0b" />
</SharedStack>
</SharedBox>
<SharedStack gap={3}>
<InfoRow label="Platform" value={league.game} />
<InfoRow label="Season" value={league.season} />
<InfoRow label="Duration" value="Oct 2025 - Feb 2026" />
<InfoRow label="Drivers" value={league.drivers} />
<InfoRow label="Races" value={league.races} last />
</SharedStack>
</SharedCard>
<SharedCard>
<SharedBox mb={4}>
<Heading level={2} icon={<SharedIcon icon={TrendingUp} size={5} color="#10b981" />}>
Sponsorship Value
</Heading>
</SharedBox>
<SharedStack gap={3}>
<InfoRow label="Total Season Views" value={league.formattedTotalImpressions} />
<InfoRow label="Projected Total" value={league.formattedProjectedTotal} />
<InfoRow label="Main Sponsor CPM" value={league.formattedMainSponsorCpm} color="text-performance-green" />
<InfoRow label="Engagement Rate" value={`${league.engagement}%`} />
<InfoRow label="League Rating" value={`${league.rating}/5.0`} last />
</SharedStack>
</SharedCard>
{league.nextRace && (
<GridItem colSpan={2}>
<SharedCard>
<SharedBox mb={4}>
<Heading level={2} icon={<SharedIcon icon={Flag} size={5} color="#f59e0b" />}>
Next Race
</Heading>
</SharedBox>
<Surface variant="muted" rounded="lg" border padding={4} style={{ background: 'rgba(245, 158, 11, 0.05)', borderColor: 'rgba(245, 158, 11, 0.2)' }}>
<SharedStack direction="row" align="center" justify="between">
<SharedStack direction="row" align="center" gap={4}>
<Surface variant="muted" rounded="lg" padding={3} style={{ background: 'rgba(245, 158, 11, 0.1)' }}>
<SharedIcon icon={Flag} size={6} color="#f59e0b" />
</Surface>
<SharedBox>
<SharedText size="lg" weight="semibold" color="text-white" block>{league.nextRace.name}</SharedText>
<SharedText size="sm" color="text-gray-400" block mt={1}>{league.nextRace.date}</SharedText>
</SharedBox>
</SharedStack>
<SharedButton variant="secondary">
View Schedule
</SharedButton>
</SharedStack>
</Surface>
</SharedCard>
</GridItem>
)}
</Grid>
)}
{activeTab === 'drivers' && (
<SharedCard p={0}>
<SharedBox p={4} borderBottom borderColor="border-neutral-800">
<Heading level={2}>Championship Standings</Heading>
<SharedText size="sm" color="text-gray-400" block mt={1}>Top drivers carrying sponsor branding</SharedText>
</SharedBox>
<SharedStack gap={0}>
{viewData.drivers.map((driver, index) => (
<SharedBox key={driver.id} p={4} borderBottom={index < viewData.drivers.length - 1} borderColor="border-neutral-800/50">
<SharedStack direction="row" align="center" justify="between">
<SharedStack direction="row" align="center" gap={4}>
<Surface variant="muted" rounded="full" padding={1} style={{ width: '2.5rem', height: '2.5rem', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#262626' }}>
<SharedText weight="bold" color="text-white">{driver.position}</SharedText>
</Surface>
<Box>
<Text size="lg" weight="semibold" color="text-white" block>{league.nextRace.name}</Text>
<Text size="sm" color="text-gray-400" block mt={1}>{league.nextRace.date}</Text>
</Box>
</Stack>
<Button variant="secondary">
View Schedule
</Button>
</Stack>
</Surface>
</Card>
</GridItem>
)}
</Grid>
)}
<SharedBox>
<SharedText weight="medium" color="text-white" block>{driver.name}</SharedText>
<SharedText size="sm" color="text-gray-500" block mt={1}>{driver.team} {driver.country}</SharedText>
</SharedBox>
</SharedStack>
<SharedStack direction="row" align="center" gap={8}>
<SharedBox textAlign="right">
<SharedText weight="medium" color="text-white" block>{driver.races}</SharedText>
<SharedText size="xs" color="text-gray-500">races</SharedText>
</SharedBox>
<SharedBox textAlign="right">
<SharedText weight="semibold" color="text-white" block>{driver.formattedImpressions}</SharedText>
<SharedText size="xs" color="text-gray-500">views</SharedText>
</SharedBox>
</SharedStack>
</SharedStack>
</SharedBox>
))}
</SharedStack>
</SharedCard>
)}
{activeTab === 'drivers' && (
<Card p={0}>
<Box p={4} borderBottom borderColor="border-neutral-800">
<Heading level={2}>Championship Standings</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>Top drivers carrying sponsor branding</Text>
</Box>
<Stack gap={0}>
{viewData.drivers.map((driver, index) => (
<Box key={driver.id} p={4} borderBottom={index < viewData.drivers.length - 1} borderColor="border-neutral-800/50">
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={4}>
<Surface variant="muted" rounded="full" padding={1} w="10" h="10" display="flex" alignItems="center" justifyContent="center" bg="bg-neutral-800">
<Text weight="bold" color="text-white">{driver.position}</Text>
</Surface>
<Box>
<Text weight="medium" color="text-white" block>{driver.name}</Text>
<Text size="sm" color="text-gray-500" block mt={1}>{driver.team} {driver.country}</Text>
</Box>
</Stack>
<Stack direction="row" align="center" gap={8}>
<Box textAlign="right">
<Text weight="medium" color="text-white" block>{driver.races}</Text>
<Text size="xs" color="text-gray-500">races</Text>
</Box>
<Box textAlign="right">
<Text weight="semibold" color="text-white" block>{driver.formattedImpressions}</Text>
<Text size="xs" color="text-gray-500">views</Text>
</Box>
</Stack>
</Stack>
</Box>
))}
</Stack>
</Card>
)}
{activeTab === 'races' && (
<SharedCard p={0}>
<SharedBox p={4} borderBottom borderColor="border-neutral-800">
<Heading level={2}>Race Calendar</Heading>
<SharedText size="sm" color="text-gray-400" block mt={1}>Season schedule with view statistics</SharedText>
</SharedBox>
<SharedStack gap={0}>
{viewData.races.map((race, index) => (
<SharedBox key={race.id} p={4} borderBottom={index < viewData.races.length - 1} borderColor="border-neutral-800/50">
<SharedStack direction="row" align="center" justify="between">
<SharedStack direction="row" align="center" gap={4}>
<SharedBox w="3" h="3" rounded="full" bg={race.status === 'completed' ? 'bg-performance-green' : 'bg-warning-amber'} />
<SharedBox>
<SharedText weight="medium" color="text-white" block>{race.name}</SharedText>
<SharedText size="sm" color="text-gray-500" block mt={1}>{race.formattedDate}</SharedText>
</SharedBox>
</SharedStack>
<SharedBox>
{race.status === 'completed' ? (
<SharedBox textAlign="right">
<SharedText weight="semibold" color="text-white" block>{race.views.toLocaleString()}</SharedText>
<SharedText size="xs" color="text-gray-500">views</SharedText>
</SharedBox>
) : (
<SponsorStatusChip status="pending" label="Upcoming" />
)}
</SharedBox>
</SharedStack>
</SharedBox>
))}
</SharedStack>
</SharedCard>
)}
{activeTab === 'races' && (
<Card p={0}>
<Box p={4} borderBottom borderColor="border-neutral-800">
<Heading level={2}>Race Calendar</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>Season schedule with view statistics</Text>
</Box>
<Stack gap={0}>
{viewData.races.map((race, index) => (
<Box key={race.id} p={4} borderBottom={index < viewData.races.length - 1} borderColor="border-neutral-800/50">
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={4}>
<Box w="3" h="3" rounded="full" bg={race.status === 'completed' ? 'bg-performance-green' : 'bg-warning-amber'} />
<Box>
<Text weight="medium" color="text-white" block>{race.name}</Text>
<Text size="sm" color="text-gray-500" block mt={1}>{race.formattedDate}</Text>
</Box>
</Stack>
<Box>
{race.status === 'completed' ? (
<Box textAlign="right">
<Text weight="semibold" color="text-white" block>{race.views.toLocaleString()}</Text>
<Text size="xs" color="text-gray-500">views</Text>
</Box>
) : (
<SponsorStatusChip status="pending" label="Upcoming" />
)}
</Box>
</Stack>
</Box>
))}
</Stack>
</Card>
)}
{activeTab === 'sponsor' && (
<Grid cols={12} gap={6}>
<GridItem colSpan={12} lgSpan={8}>
<PricingTableShell
title="Sponsorship Tiers"
tiers={pricingTiers}
selectedId={selectedTier}
onSelect={(id) => setSelectedTier(id as 'main' | 'secondary')}
/>
</GridItem>
<GridItem colSpan={12} lgSpan={4}>
<Stack gap={6}>
<SponsorBrandingPreview
name="Your Brand"
{activeTab === 'sponsor' && (
<Grid cols={12} gap={6}>
<GridItem colSpan={{ base: 12, lg: 8 }}>
<PricingTableShell
title="Sponsorship Tiers"
tiers={pricingTiers}
selectedId={selectedTier}
onSelect={(id) => setSelectedTier(id as 'main' | 'secondary')}
/>
<Card>
<Box mb={4}>
<Heading level={2} icon={<Icon icon={CreditCard} size={5} color="#3b82f6" />}>
Sponsorship Summary
</Heading>
</Box>
</GridItem>
<GridItem colSpan={{ base: 12, lg: 4 }}>
<SharedStack gap={6}>
<SponsorBrandingPreview
name="Your Brand"
/>
<Stack gap={3} mb={6}>
<InfoRow label="Selected Tier" value={`${selectedTier.charAt(0).toUpperCase() + selectedTier.slice(1)} Sponsor`} />
<InfoRow label="Season Price" value={`$${selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price}`} />
<InfoRow label={`Platform Fee (${siteConfig.fees.platformFeePercent}%)`} value={`$${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * siteConfig.fees.platformFeePercent / 100).toFixed(2)}`} />
<Box pt={4} borderTop borderColor="border-neutral-800">
<Stack direction="row" align="center" justify="between">
<Text weight="semibold" color="text-white">Total (excl. VAT)</Text>
<Text size="xl" weight="bold" color="text-white">
${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * (1 + siteConfig.fees.platformFeePercent / 100)).toFixed(2)}
</Text>
</Stack>
</Box>
</Stack>
<SharedCard>
<SharedBox mb={4}>
<Heading level={2} icon={<SharedIcon icon={CreditCard} size={5} color="#3b82f6" />}>
Sponsorship Summary
</Heading>
</SharedBox>
<SharedStack gap={3} mb={6}>
<InfoRow label="Selected Tier" value={`${selectedTier.charAt(0).toUpperCase() + selectedTier.slice(1)} Sponsor`} />
<InfoRow label="Season Price" value={`$${selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price}`} />
<InfoRow label={`Platform Fee (${siteConfig.fees.platformFeePercent}%)`} value={`$${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * siteConfig.fees.platformFeePercent / 100).toFixed(2)}`} />
<SharedBox pt={4} borderTop borderColor="border-neutral-800">
<SharedStack direction="row" align="center" justify="between">
<SharedText weight="semibold" color="text-white">Total (excl. VAT)</SharedText>
<SharedText size="xl" weight="bold" color="text-white">
${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * (1 + siteConfig.fees.platformFeePercent / 100)).toFixed(2)}
</SharedText>
</SharedStack>
</SharedBox>
</SharedStack>
<Text size="xs" color="text-gray-500" block mb={4}>
{siteConfig.vat.notice}
</Text>
<SharedText size="xs" color="text-gray-500" block mb={4}>
{siteConfig.vat.notice}
</SharedText>
<Stack direction="row" gap={3}>
<Button variant="primary" fullWidth icon={<Icon icon={Megaphone} size={4} />}>
Request Sponsorship
</Button>
<Button variant="secondary" icon={<Icon icon={FileText} size={4} />}>
Download Info Pack
</Button>
</Stack>
</Card>
</Stack>
</GridItem>
</Grid>
)}
</Stack>
</Container>
<SharedStack direction="row" gap={3}>
<SharedButton variant="primary" fullWidth icon={<SharedIcon icon={Megaphone} size={4} />}>
Request Sponsorship
</SharedButton>
<SharedButton variant="secondary" icon={<SharedIcon icon={FileText} size={4} />}>
Download Info Pack
</SharedButton>
</SharedStack>
</SharedCard>
</SharedStack>
</GridItem>
</Grid>
)}
</SharedStack>
</SharedBox>
</SharedContainer>
);
}
function InfoRow({ label, value, color = 'text-white', last }: { label: string, value: string | number, color?: string, last?: boolean }) {
return (
<Box py={2} borderBottom={!last} borderColor="border-neutral-800/50">
<Stack direction="row" align="center" justify="between">
<Text color="text-gray-400">{label}</Text>
<Text weight="medium" color={color}>{value}</Text>
</Stack>
</Box>
<SharedBox py={2} borderBottom={!last} borderColor="border-neutral-800/50">
<SharedStack direction="row" align="center" justify="between">
<SharedText color="text-gray-400">{label}</SharedText>
<SharedText weight="medium" color={color}>{value}</SharedText>
</SharedStack>
</SharedBox>
);
}

View File

@@ -0,0 +1,146 @@
'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 type { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData';
import {
SharedBox,
SharedButton,
SharedStack,
SharedText,
SharedCard
} from '@/components/shared/UIComponents';
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
interface StewardingTemplateProps extends TemplateProps<StewardingViewData> {
activeTab: 'pending' | 'history';
onTabChange: (tab: 'pending' | 'history') => void;
selectedProtest: any;
onReviewProtest: (id: string) => void;
onCloseProtestModal: () => void;
onAcceptProtest: (protestId: string, penaltyType: string, penaltyValue: number, stewardNotes: string) => Promise<void>;
onRejectProtest: (protestId: string, stewardNotes: string) => Promise<void>;
showQuickPenaltyModal: boolean;
setShowQuickPenaltyModal: (show: boolean) => void;
allPendingProtests: any[];
allResolvedProtests: any[];
racesMap: any;
driverMap: any;
currentDriverId: string;
}
export function StewardingTemplate({
viewData,
activeTab,
onTabChange,
selectedProtest,
onReviewProtest,
onCloseProtestModal,
onAcceptProtest,
onRejectProtest,
showQuickPenaltyModal,
setShowQuickPenaltyModal,
allPendingProtests,
allResolvedProtests,
racesMap,
driverMap,
currentDriverId,
}: StewardingTemplateProps) {
return (
<SharedStack gap={6}>
<StewardingStats
totalPending={viewData.totalPending}
totalResolved={viewData.totalResolved}
totalPenalties={viewData.totalPenalties}
/>
{/* Tab navigation */}
<SharedBox borderBottom borderColor="border-charcoal-outline">
<SharedStack direction="row" gap={4}>
<SharedBox
borderBottom={activeTab === 'pending'}
borderColor={activeTab === 'pending' ? 'border-primary-blue' : undefined}
>
<SharedButton
variant="ghost"
onClick={() => onTabChange('pending')}
rounded={false}
>
<SharedStack direction="row" align="center" gap={2}>
<SharedText weight="medium" color={activeTab === 'pending' ? 'text-primary-blue' : undefined}>Pending Protests</SharedText>
{viewData.totalPending > 0 && (
<SharedBox px={2} py={0.5} fontSize="0.75rem" bg="bg-warning-amber/20" color="text-warning-amber" rounded="full">
{viewData.totalPending}
</SharedBox>
)}
</SharedStack>
</SharedButton>
</SharedBox>
<SharedBox
borderBottom={activeTab === 'history'}
borderColor={activeTab === 'history' ? 'border-primary-blue' : undefined}
>
<SharedButton
variant="ghost"
onClick={() => onTabChange('history')}
rounded={false}
>
<SharedText weight="medium" color={activeTab === 'history' ? 'text-primary-blue' : undefined}>History</SharedText>
</SharedButton>
</SharedBox>
</SharedStack>
</SharedBox>
{/* Content */}
{activeTab === 'pending' ? (
<StewardingQueuePanel
protests={allPendingProtests}
onReview={onReviewProtest}
/>
) : (
<SharedCard>
<SharedBox p={6}>
<PenaltyHistoryList
protests={allResolvedProtests}
races={racesMap}
drivers={driverMap}
/>
</SharedBox>
</SharedCard>
)}
{activeTab === 'history' && (
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
)}
{selectedProtest && (
<ReviewProtestModal
protest={selectedProtest}
onClose={onCloseProtestModal}
onAccept={onAcceptProtest}
onReject={onRejectProtest}
/>
)}
{showQuickPenaltyModal && (
<QuickPenaltyModal
drivers={viewData.drivers.map(d => ({
id: d.id,
name: d.name,
iracingId: '',
country: '',
joinedAt: '',
avatarUrl: null,
}) as any)}
onClose={() => setShowQuickPenaltyModal(false)}
adminId={currentDriverId || ''}
races={viewData.races.map(r => ({ id: r.id, track: r.track, scheduledAt: new Date(r.scheduledAt) }))}
/>
)}
</SharedStack>
);
}

View File

@@ -1,19 +1,16 @@
'use client';
import { EmptyState } from '@/ui/EmptyState';
import { TeamCard } from '@/components/teams/TeamCardWrapper';
import { TeamGrid } from '@/components/teams/TeamGrid';
import { TeamLeaderboardPreview } from '@/components/teams/TeamLeaderboardPreviewWrapper';
import { TeamsDirectoryHeader } from '@/components/teams/TeamsDirectoryHeader';
import { TeamsDirectory, TeamsDirectorySection } from '@/components/teams/TeamsDirectory';
import { SharedEmptyState } from '@/components/shared/SharedEmptyState';
import type { TeamsViewData } from '@/lib/view-data/TeamsViewData';
import { Box } from '@/ui/Box';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Users } from 'lucide-react';
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
interface TeamsTemplateProps {
viewData: TeamsViewData;
interface TeamsTemplateProps extends TemplateProps<TeamsViewData> {
onTeamClick?: (teamId: string) => void;
onViewFullLeaderboard: () => void;
onCreateTeam: () => void;
@@ -23,58 +20,44 @@ export function TeamsTemplate({ viewData, onTeamClick, onViewFullLeaderboard, on
const { teams } = viewData;
return (
<Box as="main" bg="base-black" minH="screen">
<Container size="lg" py={12}>
<Stack gap={10}>
<TeamsDirectoryHeader onCreateTeam={onCreateTeam} />
<TeamsDirectory>
<TeamsDirectoryHeader onCreateTeam={onCreateTeam} />
<Box>
<Stack direction="row" align="center" gap={2} mb={6}>
<Box w="2" h="2" bg="primary-accent" />
<Text size="xs" weight="bold" color="text-gray-400" uppercase>Active Rosters</Text>
</Stack>
{teams.length > 0 ? (
<TeamGrid>
{teams.map((team) => (
<TeamCard
key={team.teamId}
id={team.teamId}
name={team.teamName}
memberCount={team.memberCount}
logo={team.logoUrl}
onClick={() => onTeamClick?.(team.teamId)}
/>
))}
</TeamGrid>
) : (
<EmptyState
icon={Users}
title="No teams yet"
description="Get started by creating your first racing team"
action={{
label: 'Create Team',
onClick: onCreateTeam,
variant: 'primary'
}}
<TeamsDirectorySection title="Active Rosters" accentColor="primary-accent">
{teams.length > 0 ? (
<TeamGrid>
{teams.map((team) => (
<TeamCard
key={team.teamId}
id={team.teamId}
name={team.teamName}
memberCount={team.memberCount}
logo={team.logoUrl}
onClick={() => onTeamClick?.(team.teamId)}
/>
)}
</Box>
))}
</TeamGrid>
) : (
<SharedEmptyState
icon={Users}
title="No teams yet"
description="Get started by creating your first racing team"
action={{
label: 'Create Team',
onClick: onCreateTeam,
variant: 'primary'
}}
/>
)}
</TeamsDirectorySection>
{/* Team Leaderboard Preview */}
<Box pt={10} borderTop borderColor="outline-steel" borderOpacity={0.3}>
<Stack direction="row" align="center" gap={2} mb={6}>
<Box w="2" h="2" bg="telemetry-aqua" />
<Text size="xs" weight="bold" color="text-gray-400" uppercase>Global Standings</Text>
</Stack>
<TeamLeaderboardPreview
topTeams={[]}
onTeamClick={(id) => onTeamClick?.(id)}
onViewFullLeaderboard={onViewFullLeaderboard}
/>
</Box>
</Stack>
</Container>
</Box>
<TeamsDirectorySection title="Global Standings" accentColor="telemetry-aqua">
<TeamLeaderboardPreview
topTeams={[]}
onTeamClick={(id) => onTeamClick?.(id)}
onViewFullLeaderboard={onViewFullLeaderboard}
/>
</TeamsDirectorySection>
</TeamsDirectory>
);
}

View File

@@ -0,0 +1,9 @@
'use client';
import { AuthLoading } from '@/components/auth/AuthLoading';
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
export function LoginLoadingTemplate({ viewData }: TemplateProps<ViewData>) {
return <AuthLoading />;
}

View File

@@ -0,0 +1,37 @@
'use client';
import { SharedContainer, SharedStack, SharedText } from '@/components/shared/UIComponents';
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
interface ErrorTemplateProps extends TemplateProps<ViewData> {
message?: string;
description?: string;
}
export function ErrorTemplate({ message = "An error occurred", description = "Please try again later" }: ErrorTemplateProps) {
return (
<SharedContainer size="lg">
<SharedStack align="center" gap={4} py={12}>
<SharedText color="text-red-400">{message}</SharedText>
<SharedText color="text-gray-400">{description}</SharedText>
</SharedStack>
</SharedContainer>
);
}
interface EmptyTemplateProps extends TemplateProps<ViewData> {
title: string;
description: string;
}
export function EmptyTemplate({ title, description }: EmptyTemplateProps) {
return (
<SharedContainer size="lg">
<SharedStack align="center" gap={2} py={12}>
<SharedText size="xl" weight="semibold" color="text-white">{title}</SharedText>
<SharedText color="text-gray-400">{description}</SharedText>
</SharedStack>
</SharedContainer>
);
}

View File

@@ -0,0 +1,35 @@
import React, { ReactNode, ElementType, forwardRef, ForwardedRef } from 'react';
import { Box, BoxProps, ResponsiveValue } from './Box';
export interface GridItemProps<T extends ElementType> extends BoxProps<T> {
as?: T;
children?: ReactNode;
colSpan?: number | ResponsiveValue<number>;
rowSpan?: number | ResponsiveValue<number>;
}
export const GridItem = forwardRef(<T extends ElementType = 'div'>(
{
children,
colSpan,
rowSpan,
as,
...props
}: GridItemProps<T>,
ref: ForwardedRef<HTMLElement>
) => {
return (
<Box
as={as}
ref={ref}
colSpan={colSpan}
// rowSpan is not directly supported by Box yet, but we can add it if needed
// or use style
{...props}
>
{children}
</Box>
);
});
GridItem.displayName = 'GridItem';

View File

@@ -21,7 +21,7 @@ export const StatusIndicator = ({
subLabel,
size = 'md',
icon
}:) => {
}: StatusIndicatorProps) => {
const activeStatus = (status || variant || 'pending') as any;
const configMap: any = {