diff --git a/apps/website/app/page.tsx b/apps/website/app/page.tsx index b738ed0e9..c56c8188e 100644 --- a/apps/website/app/page.tsx +++ b/apps/website/app/page.tsx @@ -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 }) => ; + const Template = ({ viewData }: { viewData: HomeViewData }) => ; return ; } diff --git a/apps/website/app/races/[id]/page.tsx b/apps/website/app/races/[id]/page.tsx index bfec3bae5..2217dcfe4 100644 --- a/apps/website/app/races/[id]/page.tsx +++ b/apps/website/app/races/[id]/page.tsx @@ -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'; diff --git a/apps/website/app/races/[id]/results/page.tsx b/apps/website/app/races/[id]/results/page.tsx index 05183e2bd..5e680c72a 100644 --- a/apps/website/app/races/[id]/results/page.tsx +++ b/apps/website/app/races/[id]/results/page.tsx @@ -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'; diff --git a/apps/website/app/sponsor/leagues/[id]/page.tsx b/apps/website/app/sponsor/leagues/[id]/page.tsx index cfbdfa610..33897c30f 100644 --- a/apps/website/app/sponsor/leagues/[id]/page.tsx +++ b/apps/website/app/sponsor/leagues/[id]/page.tsx @@ -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'; diff --git a/apps/website/app/sponsor/leagues/page.tsx b/apps/website/app/sponsor/leagues/page.tsx index af1e355d4..d1779af33 100644 --- a/apps/website/app/sponsor/leagues/page.tsx +++ b/apps/website/app/sponsor/leagues/page.tsx @@ -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'; diff --git a/apps/website/client-wrapper/AdminDashboardWrapper.tsx b/apps/website/client-wrapper/AdminDashboardWrapper.tsx index a73e6f333..f44108a34 100644 --- a/apps/website/client-wrapper/AdminDashboardWrapper.tsx +++ b/apps/website/client-wrapper/AdminDashboardWrapper.tsx @@ -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) { const router = useRouter(); // UI state (not business logic) @@ -24,7 +21,7 @@ export function AdminDashboardWrapper({ initialViewData }: AdminDashboardWrapper return ( diff --git a/apps/website/client-wrapper/AdminUsersWrapper.tsx b/apps/website/client-wrapper/AdminUsersWrapper.tsx index b2f5e3aa0..de8ece58b 100644 --- a/apps/website/client-wrapper/AdminUsersWrapper.tsx +++ b/apps/website/client-wrapper/AdminUsersWrapper.tsx @@ -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) { 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 ( <> - setUserToDelete(null)} onConfirm={confirmDeleteUser} diff --git a/apps/website/client-wrapper/CreateLeagueWizard.tsx b/apps/website/client-wrapper/CreateLeagueWizard.tsx index 598ded00f..7ef2b437d 100644 --- a/apps/website/client-wrapper/CreateLeagueWizard.tsx +++ b/apps/website/client-wrapper/CreateLeagueWizard.tsx @@ -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(() => 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 season’s 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 season’s 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 ( - - {/* Header with icon */} - - - - - - - - Create a new league - - - We'll also set up your first season in {steps.length} easy steps. - - - A league is your long-term brand. Each season is a block of races you can run again and again. - - - - - - {/* Desktop Progress Bar */} - - - {/* Background track */} - - {/* Progress fill */} - - - - {steps.map((wizardStep) => { - const isCompleted = wizardStep.id < step; - const isCurrent = wizardStep.id === step; - const isAccessible = wizardStep.id <= highestCompletedStep; - const StepIcon = wizardStep.icon; - - return ( - 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} - > - - {isCompleted ? ( - - ) : ( - - )} - - - - {wizardStep.label} - - - - ); - })} - - - - - {/* Mobile Progress */} - - - - - {currentStepData?.label} - - - {step}/{steps.length} - - - - - - {/* Step dots */} - - {steps.map((s) => ( - - ))} - - - - {/* Main Card */} - - {/* Top gradient accent */} - - - {/* Step header */} - - - - - - - - {getStepTitle(step)} - - {getStepContextLabel(step)} - - - - - {getStepSubtitle(step)} - - - - Step - {step} - / {steps.length} - - - - {/* Divider */} - - - {/* Step content with min-height for consistency */} - - {step === 1 && ( - - - - - - - First season - - - Name the first season that will run in this league. - - - - - - Season name - - - setForm((prev) => ({ - ...prev, - seasonName: e.target.value, - })) - } - placeholder="e.g., Season 1 (2025)" - /> - - Seasons are the individual competitive runs inside your league. You can run Season 2, Season 3, or parallel seasons later. - - - - - )} - - {step === 2 && ( - - - - )} - - {step === 3 && ( - - - - Applies to: First season of this league. - - - These settings only affect this season. Future seasons can use different formats. - - - - - )} - - {step === 4 && ( - - - - Applies to: First season of this league. - - - These settings only affect this season. Future seasons can use different formats. - - - - - )} - - {step === 5 && ( - - - - Applies to: First season of this league. - - - These settings only affect this season. Future seasons can use different formats. - - - {/* Scoring Pattern Selection */} - - setForm((prev) => ({ - ...prev, - scoring: { - ...prev.scoring, - customScoringEnabled: !(prev.scoring?.customScoringEnabled), - }, - })) - } - /> - - {/* Divider */} - - - {/* Championships & Drop Rules side by side on larger screens */} - - - - - - {errors.submit && ( - - - {errors.submit} - - )} - - )} - - {step === 6 && ( - - - - Applies to: First season of this league. - - - These settings only affect this season. Future seasons can use different formats. - - - - - )} - - {step === 7 && ( - - - {errors.submit && ( - - - {errors.submit} - - )} - - )} - - - - {/* Navigation */} - - - - - {/* Mobile step dots */} - - {steps.map((s) => ( - - ))} - - - {step < 7 ? ( - - ) : ( - - )} - - - - {/* Helper text */} - - This will create your league and its first season. You can edit both later. - - + + setForm((prev) => ({ + ...prev, + scoring: { + ...prev.scoring, + customScoringEnabled: !(prev.scoring?.customScoringEnabled), + }, + })) + } + getStepTitle={getStepTitle} + getStepSubtitle={getStepSubtitle} + getStepContextLabel={getStepContextLabel} + /> ); } diff --git a/apps/website/client-wrapper/DriverProfilePageClient.tsx b/apps/website/client-wrapper/DriverProfilePageClient.tsx index bb44d53ae..12ff3a679 100644 --- a/apps/website/client-wrapper/DriverProfilePageClient.tsx +++ b/apps/website/client-wrapper/DriverProfilePageClient.tsx @@ -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 ( - - - Error loading driver profile - Please try again later - - + ); } if (!viewData || !viewData.currentDriver) { if (empty) { return ( - - - {empty.title} - {empty.description} - - + ); } return null; @@ -78,4 +68,4 @@ export function DriverProfilePageClient({ viewData, error, empty }: DriverProfil onTabChange={setActiveTab} /> ); -} \ No newline at end of file +} diff --git a/apps/website/client-wrapper/DriverRankingsPageClient.tsx b/apps/website/client-wrapper/DriverRankingsPageClient.tsx index 26edf2680..acb9a0e6b 100644 --- a/apps/website/client-wrapper/DriverRankingsPageClient.tsx +++ b/apps/website/client-wrapper/DriverRankingsPageClient.tsx @@ -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) { const router = useRouter(); const [searchQuery, setSearchQuery] = useState(''); diff --git a/apps/website/client-wrapper/DriversPageClient.tsx b/apps/website/client-wrapper/DriversPageClient.tsx index fa73c19c2..98e9d27dc 100644 --- a/apps/website/client-wrapper/DriversPageClient.tsx +++ b/apps/website/client-wrapper/DriversPageClient.tsx @@ -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 ( - - - Error loading drivers - Please try again later - - + ); } if (!viewData || viewData.drivers.length === 0) { if (empty) { return ( - - - {empty.title} - {empty.description} - - + ); } return null; @@ -86,4 +74,4 @@ export function DriversPageClient({ viewData, error, empty }: DriversPageClientP onViewLeaderboard={handleViewLeaderboard} /> ); -} \ No newline at end of file +} diff --git a/apps/website/client-wrapper/ForgotPasswordClient.tsx b/apps/website/client-wrapper/ForgotPasswordClient.tsx index 6eae2762e..1f46fa9d8 100644 --- a/apps/website/client-wrapper/ForgotPasswordClient.tsx +++ b/apps/website/client-wrapper/ForgotPasswordClient.tsx @@ -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) { // Build ViewModel from ViewData const [viewModel, setViewModel] = useState(() => ForgotPasswordViewModelBuilder.build(viewData) @@ -127,4 +124,4 @@ export function ForgotPasswordClient({ viewData }: ForgotPasswordClientProps) { }} /> ); -} \ No newline at end of file +} diff --git a/apps/website/client-wrapper/LeaderboardsPageClient.tsx b/apps/website/client-wrapper/LeaderboardsPageClient.tsx index 5e114f539..05bb29c9a 100644 --- a/apps/website/client-wrapper/LeaderboardsPageClient.tsx +++ b/apps/website/client-wrapper/LeaderboardsPageClient.tsx @@ -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) { const router = useRouter(); const handleDriverClick = (id: string) => { diff --git a/apps/website/client-wrapper/LeagueAdminSchedulePageClient.tsx b/apps/website/client-wrapper/LeagueAdminSchedulePageClient.tsx index 10c9d026d..d9fe55161 100644 --- a/apps/website/client-wrapper/LeagueAdminSchedulePageClient.tsx +++ b/apps/website/client-wrapper/LeagueAdminSchedulePageClient.tsx @@ -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 ( - - - + + + Admin Access Required - - Only league admins can manage the schedule. - - - - + + Only league admins can manage the schedule. + + + + ); } - // Template component that wraps the actual template with all props - const TemplateWrapper = ({ data }: { data: typeof templateData }) => { - if (!data) return null; - - return ( - <> - { - 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())); - }} - /> - 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 ( - ( + <> + { + 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())); + }} + /> + 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', diff --git a/apps/website/client-wrapper/LeagueRulebookPageClient.tsx b/apps/website/client-wrapper/LeagueRulebookPageClient.tsx index de1d32cd3..e60794514 100644 --- a/apps/website/client-wrapper/LeagueRulebookPageClient.tsx +++ b/apps/website/client-wrapper/LeagueRulebookPageClient.tsx @@ -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) { const [activeSection, setActiveSection] = useState('scoring'); return ( diff --git a/apps/website/client-wrapper/LeagueWalletPageClient.tsx b/apps/website/client-wrapper/LeagueWalletPageClient.tsx index 4a70ebb2c..dc98b5762 100644 --- a/apps/website/client-wrapper/LeagueWalletPageClient.tsx +++ b/apps/website/client-wrapper/LeagueWalletPageClient.tsx @@ -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 { 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 ( - - {/* Header */} - - - League Wallet - Manage your league's finances and payouts - - - - - {}} // Not implemented for leagues yet - onWithdraw={() => {}} // Not implemented for leagues yet - /> - - {/* Alpha Notice */} - - - Alpha Note: Wallet management is demonstration-only. - Real payment processing and bank integrations will be available when the payment system is fully implemented. - - - + ); } diff --git a/apps/website/client-wrapper/LeaguesPageClient.tsx b/apps/website/client-wrapper/LeaguesPageClient.tsx index ea559ae2f..82f5379ae 100644 --- a/apps/website/client-wrapper/LeaguesPageClient.tsx +++ b/apps/website/client-wrapper/LeaguesPageClient.tsx @@ -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) { const router = useRouter(); const [searchQuery, setSearchQuery] = useState(''); const [activeCategory, setActiveCategory] = useState('all'); @@ -174,107 +132,17 @@ export function LeaguesPageClient({ viewData }: LeaguesTemplateProps) { }); return ( - - - {/* Hero */} - - - - - Competition Hub - - - Find Your Grid - - - From casual sprints to epic endurance battles — discover the perfect league for your racing style. - - - - - - {viewData.leagues.length} - Active Leagues - - - - - - - {/* Search & Filters */} - - ) => setSearchQuery(e.target.value)} - icon={} - /> - - - {CATEGORIES.map((category) => { - const isActive = activeCategory === category.id; - const CategoryIcon = category.icon; - return ( - - ); - })} - - - - {/* Grid */} - - {filteredLeagues.length > 0 ? ( - - {filteredLeagues.map((league) => ( - router.push(routes.league.detail(league.id))} - /> - ))} - - ) : ( - - - - - No Leagues Found - Try adjusting your search or filters - - - )} - - - + router.push(routes.league.create)} + onLeagueClick={(id) => router.push(routes.league.detail(id))} + onClearFilters={() => { setSearchQuery(''); setActiveCategory('all'); }} + /> ); } diff --git a/apps/website/client-wrapper/LoginClient.tsx b/apps/website/client-wrapper/LoginClient.tsx index de2d049b3..fb0d0d9d9 100644 --- a/apps/website/client-wrapper/LoginClient.tsx +++ b/apps/website/client-wrapper/LoginClient.tsx @@ -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) { 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 ; + return ; } // If user has insufficient permissions, show permission error @@ -264,4 +261,4 @@ export function LoginClient({ viewData }: LoginClientProps) { }} /> ); -} \ No newline at end of file +} diff --git a/apps/website/client-wrapper/MediaPageClient.tsx b/apps/website/client-wrapper/MediaPageClient.tsx index 7a044382e..6b28d09c4 100644 --- a/apps/website/client-wrapper/MediaPageClient.tsx +++ b/apps/website/client-wrapper/MediaPageClient.tsx @@ -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) { return ( ); } diff --git a/apps/website/client-wrapper/NotFoundPageClient.tsx b/apps/website/client-wrapper/NotFoundPageClient.tsx index e2ebdf806..9db876f4f 100644 --- a/apps/website/client-wrapper/NotFoundPageClient.tsx +++ b/apps/website/client-wrapper/NotFoundPageClient.tsx @@ -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>) { 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.', diff --git a/apps/website/client-wrapper/OnboardingWizardClient.tsx b/apps/website/client-wrapper/OnboardingWizardClient.tsx index 76fd1db9e..49fb43187 100644 --- a/apps/website/client-wrapper/OnboardingWizardClient.tsx +++ b/apps/website/client-wrapper/OnboardingWizardClient.tsx @@ -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>) { const { session } = useAuth(); const [isProcessing, setIsProcessing] = useState(false); const [step, setStep] = useState(1); diff --git a/apps/website/client-wrapper/ProfileLiveryUploadPageClient.tsx b/apps/website/client-wrapper/ProfileLiveryUploadPageClient.tsx index 84ffd0282..814975f89 100644 --- a/apps/website/client-wrapper/ProfileLiveryUploadPageClient.tsx +++ b/apps/website/client-wrapper/ProfileLiveryUploadPageClient.tsx @@ -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>) { const [selectedFile, setSelectedFile] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const [isUploading, setIsUploading] = useState(false); @@ -40,70 +32,13 @@ export function ProfileLiveryUploadPageClient() { }; return ( - - - Upload livery - - Upload your custom car livery. Supported formats: .png, .jpg, .tga - - - - - - - - - - - - - - - - - - - {previewUrl ? ( - - - - - - ) : ( - - - Select a file to see preview and details - - - )} - - - + ); } diff --git a/apps/website/client-wrapper/ProfilePageClient.tsx b/apps/website/client-wrapper/ProfilePageClient.tsx index b64c41a60..ab08f9546 100644 --- a/apps/website/client-wrapper/ProfilePageClient.tsx +++ b/apps/website/client-wrapper/ProfilePageClient.tsx @@ -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 { mode: 'profile-exists' | 'needs-profile'; } diff --git a/apps/website/client-wrapper/ProfileSettingsPageClient.tsx b/apps/website/client-wrapper/ProfileSettingsPageClient.tsx index df0bd662e..bdb369458 100644 --- a/apps/website/client-wrapper/ProfileSettingsPageClient.tsx +++ b/apps/website/client-wrapper/ProfileSettingsPageClient.tsx @@ -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 { onSave: (updates: { bio?: string; country?: string }) => Promise>; } @@ -41,15 +46,19 @@ export function ProfileSettingsPageClient({ viewData, onSave }: ProfileSettingsP return ( <> - + {error && ( - - - + + + + + + Update Failed + {error} + + + + )} ( - - {children} - -); - -type PenaltyUiConfig = { - label: string; - description: string; - icon: LucideIcon; - color: string; - defaultValue?: number; -}; - -const PENALTY_UI: Record = { +const PENALTY_UI: Record = { time_penalty: { label: 'Time Penalty', description: 'Add seconds to race result', @@ -105,7 +72,7 @@ const PENALTY_UI: Record = { }, }; -export function ProtestDetailPageClient({ initialViewData }: { initialViewData: unknown }) { +export function ProtestDetailPageClient({ viewData: initialViewData }: Partial>) { 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 ; - } - - // Show access denied if not admin - if (!isAdmin) { - return ( - - - - - - Admin Access Required - - - Only league admins can review protests. - - - - - ); - } - return ( - - {/* 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 ( - - {/* Compact Header */} - - - - - - - Protest Review - - - {statusConfig.label} - - {daysSinceFiled > 2 && isPending && ( - - - {daysSinceFiled}d old - - )} - - - - - {/* Main Layout: Feed + Sidebar */} - - {/* Left Sidebar - Incident Info */} - - - {/* Drivers Involved */} - - - Parties Involved - - - {/* Protesting Driver */} - - - - - - - Protesting - {protestingDriver?.name || 'Unknown'} - - - - - - {/* Accused Driver */} - - - - - - - Accused - {accusedDriver?.name || 'Unknown'} - - - - - - - - - {/* Race Info */} - - - Race Details - - - - - - {race?.name || 'Unknown Race'} - - - - - - - - - - {race?.name || 'Unknown Track'} - - - - {race?.formattedDate || (race?.scheduledAt ? new Date(race.scheduledAt).toLocaleDateString() : 'Unknown Date')} - - {protest.incident?.lap && ( - - - Lap {protest.incident.lap} - - )} - - - - - {protest.proofVideoUrl && ( - - - Evidence - - - - Watch Video - - - - - - )} - - {/* Quick Stats */} - - - Timeline - - - Filed - {new Date(submittedAt).toLocaleDateString()} - - - Age - 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days - - {protest.reviewedAt && ( - - Resolved - {new Date(protest.reviewedAt).toLocaleDateString()} - - )} - - - - - - - {/* Center - Discussion Feed */} - - - {/* Timeline / Feed */} - - - Discussion - - - - {/* Initial Protest Filing */} - - - - - - - - {protestingDriver?.name || 'Unknown'} - filed protest - - {new Date(submittedAt).toLocaleString()} - - - - {protest.description || pd.incident?.description} - - {(protest.comment || pd.comment) && ( - - Additional details: - {protest.comment || pd.comment} - - )} - - - - - - {/* Defense placeholder */} - {protest.status === 'awaiting_defense' && ( - - - - - - - Defense Requested - Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense... - - - - )} - - {/* Decision (if resolved) */} - {(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && ( - - - - - - - - Steward Decision - - {protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'} - - {protest.reviewedAt && ( - <> - - {new Date(protest.reviewedAt).toLocaleString()} - - )} - - - - {protest.decisionNotes} - - - - - )} - - - {/* Add Comment */} - {isPending && ( - - - - - - - ) => 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" - /> - - - - - - - )} - - - - - {/* Right Sidebar - Actions */} - - - {isPending && ( - <> - {/* Quick Actions */} - - - Actions - - - - - - - - - - {/* Decision Panel */} - {showDecisionPanel && ( - - - Stewarding Decision - - {/* Decision Selection */} - - - - - - - - - - {/* Penalty Selection (if upholding) */} - {decision === 'uphold' && ( - - Penalty Type - - {penaltyTypes.length === 0 ? ( - - Loading penalty types... - - ) : ( - <> - - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - {penaltyTypes.map((penalty: any) => { - const Icon = penalty.icon; - const isSelected = penaltyType === penalty.type; - return ( - - - - ); - })} - - - {selectedPenalty?.requiresValue && ( - - - Value ({selectedPenalty.valueLabel}) - - ) => 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" - /> - - )} - - )} - - )} - - {/* Steward Notes */} - - Decision Reasoning * - ) => 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" - /> - - - {/* Submit */} - - - - )} - - )} - - {/* Already Resolved Info */} - {!isPending && ( - - - - - Case Closed - - {protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'} - - - - - )} - - - - - ); - }} - + ); } diff --git a/apps/website/client-wrapper/RaceDetailPageClient.tsx b/apps/website/client-wrapper/RaceDetailPageClient.tsx index 0fd5c9e23..a8d266fc9 100644 --- a/apps/website/client-wrapper/RaceDetailPageClient.tsx +++ b/apps/website/client-wrapper/RaceDetailPageClient.tsx @@ -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) { const router = useRouter(); const [animatedRatingChange] = useState(0); diff --git a/apps/website/client-wrapper/RaceResultsPageClient.tsx b/apps/website/client-wrapper/RaceResultsPageClient.tsx index 260090659..401938ec6 100644 --- a/apps/website/client-wrapper/RaceResultsPageClient.tsx +++ b/apps/website/client-wrapper/RaceResultsPageClient.tsx @@ -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) { const router = useRouter(); const [importing, setImporting] = useState(false); const [importSuccess, setImportSuccess] = useState(false); diff --git a/apps/website/client-wrapper/RacesAllPageClient.tsx b/apps/website/client-wrapper/RacesAllPageClient.tsx index 745c56875..79b82341a 100644 --- a/apps/website/client-wrapper/RacesAllPageClient.tsx +++ b/apps/website/client-wrapper/RacesAllPageClient.tsx @@ -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) { const router = useRouter(); // Client-side state for filters and pagination @@ -107,4 +107,4 @@ export function RacesAllPageClient({ initialViewData }: { initialViewData: Races }} /> ); -} \ No newline at end of file +} diff --git a/apps/website/client-wrapper/RacesPageClient.tsx b/apps/website/client-wrapper/RacesPageClient.tsx index 15c363c5f..5770ad78f 100644 --- a/apps/website/client-wrapper/RacesPageClient.tsx +++ b/apps/website/client-wrapper/RacesPageClient.tsx @@ -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) { const router = useRouter(); const [statusFilter, setStatusFilter] = useState('all'); const [leagueFilter, setLeagueFilter] = useState('all'); diff --git a/apps/website/client-wrapper/ResetPasswordClient.tsx b/apps/website/client-wrapper/ResetPasswordClient.tsx index 574e9be32..846ca489d 100644 --- a/apps/website/client-wrapper/ResetPasswordClient.tsx +++ b/apps/website/client-wrapper/ResetPasswordClient.tsx @@ -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) { const router = useRouter(); const searchParams = useSearchParams(); diff --git a/apps/website/client-wrapper/RosterAdminPage.tsx b/apps/website/client-wrapper/RosterAdminPage.tsx index 7311d76e0..1b1fb9291 100644 --- a/apps/website/client-wrapper/RosterAdminPage.tsx +++ b/apps/website/client-wrapper/RosterAdminPage.tsx @@ -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>) { const params = useParams(); const leagueId = params.id as string; diff --git a/apps/website/client-wrapper/ServerErrorPageClient.tsx b/apps/website/client-wrapper/ServerErrorPageClient.tsx index d55595d18..7f9f04b7d 100644 --- a/apps/website/client-wrapper/ServerErrorPageClient.tsx +++ b/apps/website/client-wrapper/ServerErrorPageClient.tsx @@ -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>) { 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 }; diff --git a/apps/website/client-wrapper/SignupClient.tsx b/apps/website/client-wrapper/SignupClient.tsx index 839c0eb2d..859d3c391 100644 --- a/apps/website/client-wrapper/SignupClient.tsx +++ b/apps/website/client-wrapper/SignupClient.tsx @@ -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) { 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) { }} /> ); -} \ No newline at end of file +} diff --git a/apps/website/client-wrapper/SponsorLeagueDetailPageClient.tsx b/apps/website/client-wrapper/SponsorLeagueDetailPageClient.tsx index 000e1e3b9..fb39f11ff 100644 --- a/apps/website/client-wrapper/SponsorLeagueDetailPageClient.tsx +++ b/apps/website/client-wrapper/SponsorLeagueDetailPageClient.tsx @@ -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) { const [activeTab, setActiveTab] = useState<'overview' | 'drivers' | 'races' | 'sponsor'>('overview'); const [selectedTier, setSelectedTier] = useState<'main' | 'secondary'>('main'); return ( ) { const [searchQuery, setSearchQuery] = useState(''); const [tierFilter] = useState('all'); const [availabilityFilter] = useState('all'); const [sortBy] = useState('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 ( { onAccept: (requestId: string) => Promise>; onReject: (requestId: string, reason?: string) => Promise>; } @@ -48,15 +53,19 @@ export function SponsorshipRequestsClient({ viewData, onAccept, onReject }: Spon return ( <> - + {error && ( - - - + + + + + + Action Failed + {error} + + + + )} { onAccept: (requestId: string) => Promise>; onReject: (requestId: string, reason?: string) => Promise>; } diff --git a/apps/website/client-wrapper/StewardingPageClient.tsx b/apps/website/client-wrapper/StewardingPageClient.tsx index a914db385..85862f937 100644 --- a/apps/website/client-wrapper/StewardingPageClient.tsx +++ b/apps/website/client-wrapper/StewardingPageClient.tsx @@ -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 { 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(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 = {}; - 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 = {}; - 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 ( - - - - {/* Tab navigation */} - - - - - - - - - - - - {/* Content */} - {activeTab === 'pending' ? ( - - ) : ( - - - - - - )} - - {activeTab === 'history' && ( - setShowQuickPenaltyModal(true)} /> - )} - - {selectedProtest && ( - setSelectedProtest(null)} - onAccept={handleAcceptProtest} - onReject={handleRejectProtest} - /> - )} - - {showQuickPenaltyModal && ( - 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) }))} - /> - )} - + setSelectedProtest(null)} + onAcceptProtest={handleAcceptProtest} + onRejectProtest={handleRejectProtest} + showQuickPenaltyModal={showQuickPenaltyModal} + setShowQuickPenaltyModal={setShowQuickPenaltyModal} + allPendingProtests={allPendingProtests} + allResolvedProtests={allResolvedProtests} + racesMap={racesMap} + driverMap={driverMap} + currentDriverId={currentDriverId} + /> ); } diff --git a/apps/website/client-wrapper/TeamDetailPageClient.tsx b/apps/website/client-wrapper/TeamDetailPageClient.tsx index 92b84ac70..5466db88a 100644 --- a/apps/website/client-wrapper/TeamDetailPageClient.tsx +++ b/apps/website/client-wrapper/TeamDetailPageClient.tsx @@ -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) { const router = useRouter(); // UI state only (no business logic) @@ -51,4 +48,4 @@ export function TeamDetailPageClient({ viewData }: TeamDetailPageClientProps) { onGoBack={handleGoBack} /> ); -} \ No newline at end of file +} diff --git a/apps/website/client-wrapper/TeamLeaderboardPageWrapper.tsx b/apps/website/client-wrapper/TeamLeaderboardPageWrapper.tsx index ae3696855..b9a3d3df0 100644 --- a/apps/website/client-wrapper/TeamLeaderboardPageWrapper.tsx +++ b/apps/website/client-wrapper/TeamLeaderboardPageWrapper.tsx @@ -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) { 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('all'); const [sortBy, setSortBy] = useState('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 ( + + + {children} + + + + ); +} diff --git a/apps/website/components/leagues/CreateLeagueWizardLayout.tsx b/apps/website/components/leagues/CreateLeagueWizardLayout.tsx new file mode 100644 index 000000000..c2d67264c --- /dev/null +++ b/apps/website/components/leagues/CreateLeagueWizardLayout.tsx @@ -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 ( + + {header} + {progress} + {children} + {navigation} + {footer} + + ); +} diff --git a/apps/website/components/races/RacesAllLayout.tsx b/apps/website/components/races/RacesAllLayout.tsx new file mode 100644 index 000000000..933b3f535 --- /dev/null +++ b/apps/website/components/races/RacesAllLayout.tsx @@ -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 ( + + + + {header} + {stats} + {children} + {pagination} + + + + ); +} + +export function RacesAllStats({ count, onFilterClick }: { count: number, onFilterClick: () => void }) { + return ( + + + Showing {count} races + + + Filters + + + ); +} diff --git a/apps/website/components/races/RacesLayout.tsx b/apps/website/components/races/RacesLayout.tsx new file mode 100644 index 000000000..afd67b39e --- /dev/null +++ b/apps/website/components/races/RacesLayout.tsx @@ -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 ( + + + + {header} + {banner} + + + {children} + + + {sidebar} + + + + + + ); +} + +export function RaceScheduleSection({ children, title }: { children: ReactNode, title: string }) { + return ( + + + {title} + + {children} + + ); +} diff --git a/apps/website/components/shared/SharedEmptyState.tsx b/apps/website/components/shared/SharedEmptyState.tsx new file mode 100644 index 000000000..13217594c --- /dev/null +++ b/apps/website/components/shared/SharedEmptyState.tsx @@ -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 ( + + ); +} diff --git a/apps/website/components/shared/UIComponents.tsx b/apps/website/components/shared/UIComponents.tsx new file mode 100644 index 000000000..73d3cfa14 --- /dev/null +++ b/apps/website/components/shared/UIComponents.tsx @@ -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 +}; diff --git a/apps/website/components/shared/state/PageWrapper.tsx b/apps/website/components/shared/state/PageWrapper.tsx index 75c605731..b7d4903d3 100644 --- a/apps/website/components/shared/state/PageWrapper.tsx +++ b/apps/website/components/shared/state/PageWrapper.tsx @@ -40,7 +40,7 @@ export interface PageWrapperProps { /** 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({ // 4. Success State - Render Template with data return ( -