({
}
// Re-export types for convenience
-export type { PageWrapperProps, PageWrapperLoadingConfig, PageWrapperErrorConfig, PageWrapperEmptyConfig } from '@/ui/PageWrapper';
\ No newline at end of file
+export type { PageWrapperProps, PageWrapperLoadingConfig, PageWrapperErrorConfig, PageWrapperEmptyConfig } from './PageWrapper';
diff --git a/apps/website/components/teams/TeamsDirectory.tsx b/apps/website/components/teams/TeamsDirectory.tsx
new file mode 100644
index 000000000..d39b58138
--- /dev/null
+++ b/apps/website/components/teams/TeamsDirectory.tsx
@@ -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 (
+
+
+
+
+ {title && (
+
+
+ {title}
+
+ )}
+ {children}
+
+
+
+
+ );
+}
+
+export function TeamsDirectorySection({ children, title, accentColor = "primary-accent" }: { children: ReactNode, title: string, accentColor?: string }) {
+ return (
+
+
+
+ {title}
+
+ {children}
+
+ );
+}
diff --git a/apps/website/lib/contracts/components/ComponentContracts.ts b/apps/website/lib/contracts/components/ComponentContracts.ts
new file mode 100644
index 000000000..41d721fc0
--- /dev/null
+++ b/apps/website/lib/contracts/components/ComponentContracts.ts
@@ -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> = (props: P) => ReactElement | null;
+
+export interface TemplateProps {
+ 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 = ClientWrapperProps> =
+ (props: P) => ReactElement> | null;
+
+export interface ClientWrapperProps {
+ viewData: T;
+}
diff --git a/apps/website/lib/contracts/view-data/ViewData.ts b/apps/website/lib/contracts/view-data/ViewData.ts
index ae07b7a84..60eee2f51 100644
--- a/apps/website/lib/contracts/view-data/ViewData.ts
+++ b/apps/website/lib/contracts/view-data/ViewData.ts
@@ -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;
}
/**
diff --git a/apps/website/lib/view-data/LeagueDetailViewData.ts b/apps/website/lib/view-data/LeagueDetailViewData.ts
index 380610d06..9c60fccc7 100644
--- a/apps/website/lib/view-data/LeagueDetailViewData.ts
+++ b/apps/website/lib/view-data/LeagueDetailViewData.ts
@@ -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;
-}
\ No newline at end of file
+}
diff --git a/apps/website/lib/view-data/TeamsViewData.ts b/apps/website/lib/view-data/TeamsViewData.ts
index f783ec4b1..1c4d98e31 100644
--- a/apps/website/lib/view-data/TeamsViewData.ts
+++ b/apps/website/lib/view-data/TeamsViewData.ts
@@ -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[];
}
diff --git a/apps/website/templates/AdminDashboardTemplate.tsx b/apps/website/templates/AdminDashboardTemplate.tsx
index caf981632..d9fe71df8 100644
--- a/apps/website/templates/AdminDashboardTemplate.tsx
+++ b/apps/website/templates/AdminDashboardTemplate.tsx
@@ -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 & {
onRefresh: () => void;
isLoading: boolean;
}) {
@@ -68,103 +70,93 @@ export function AdminDashboardTemplate({
];
return (
-
-
- }
- >
- Refresh Telemetry
-
- }
- />
+
+
+
+ }
+ >
+ Refresh Telemetry
+
+ }
+ />
-
+
-
- {/* System Health & Status */}
-
-
-
- Operational
-
- }
- />
-
-
-
-
-
- Suspended Users
- {viewData.stats.suspendedUsers}
-
-
-
-
-
- Deleted Users
- {viewData.stats.deletedUsers}
-
-
-
-
-
- New Registrations (24h)
- {viewData.stats.newUsersToday}
-
-
-
-
-
+
+ {/* System Health & Status */}
+
+
+
+
+
+ Operational
+
+
+ }
+ />
+
+
+
+
+
+ Suspended Users
+ {viewData.stats.suspendedUsers}
+
+
+
+
+
+ Deleted Users
+ {viewData.stats.deletedUsers}
+
+
+
+
+
+ New Registrations (24h)
+ {viewData.stats.newUsersToday}
+
+
+
+
+
- {/* Quick Operations */}
-
-
-
-
-
-
- User Management
-
-
-
-
-
- Security & Roles
-
-
-
-
-
- System Audit Logs
-
-
-
-
-
-
-
+ {/* Quick Operations */}
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+ Enter Maintenance Mode
+
+
+
+
+
);
}
diff --git a/apps/website/templates/AdminUsersTemplate.tsx b/apps/website/templates/AdminUsersTemplate.tsx
index aefb4cd44..7f7855e4e 100644
--- a/apps/website/templates/AdminUsersTemplate.tsx
+++ b/apps/website/templates/AdminUsersTemplate.tsx
@@ -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 {
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 (
-
-
- }
- >
- Refresh Data
-
- }
- />
-
- {error && (
-
+
+
+ }
+ >
+ Refresh Data
+
+ }
/>
- )}
-
-
-
-
-
- {viewData.users.length === 0 && !loading ? (
-
- Clear All Filters
-
- }
- />
- ) : (
-
+ {/* error notice should be a component */}
+ {error && (
+
+
+
+
+ Operation Failed
+ {error}
+
+
+
)}
-
-
-
-
+
+
+
+
+
+ {viewData.users.length === 0 && !loading ? (
+
+ Clear All Filters
+
+ }
+ />
+ ) : (
+
+ )}
+
+
+
+
+
+
);
}
diff --git a/apps/website/templates/CreateLeagueWizardTemplate.tsx b/apps/website/templates/CreateLeagueWizardTemplate.tsx
new file mode 100644
index 000000000..836a0bf50
--- /dev/null
+++ b/apps/website/templates/CreateLeagueWizardTemplate.tsx
@@ -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 {
+ 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 (
+
+ {/* 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 (
+ 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}
+ >
+
+ {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
+
+
+ onFormChange({
+ ...form,
+ 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 */}
+
+
+ {/* 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 */}
+
+ }
+ >
+ Back
+
+
+
+ {/* Mobile step dots */}
+
+ {steps.map((s) => (
+
+ ))}
+
+
+ {step < 7 ? (
+ }
+ >
+ Continue
+
+ ) : (
+ : }
+ >
+ {loading ? (
+ Creating…
+ ) : (
+ Create League
+ )}
+
+ )}
+
+
+
+ {/* Helper text */}
+
+ This will create your league and its first season. You can edit both later.
+
+
+ );
+}
diff --git a/apps/website/templates/LeagueDetailTemplate.tsx b/apps/website/templates/LeagueDetailTemplate.tsx
index bc15f9b0c..8bc2b0262 100644
--- a/apps/website/templates/LeagueDetailTemplate.tsx
+++ b/apps/website/templates/LeagueDetailTemplate.tsx
@@ -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) {
return (
-
-
- {/* Breadcrumbs */}
-
-
- Home
-
-
-
- Leagues
-
-
- {viewData.name}
-
-
-
-
-
-
-
- {children}
-
-
-
+
+
+
+
+
+
+ Leagues
+
+
+ {viewData.name}
+
+
+ {/* ... rest of the template ... */}
+
+
+
);
}
+
+import { SharedIcon } from '@/components/shared/UIComponents';
diff --git a/apps/website/templates/LeagueWalletTemplate.tsx b/apps/website/templates/LeagueWalletTemplate.tsx
new file mode 100644
index 000000000..20a528e64
--- /dev/null
+++ b/apps/website/templates/LeagueWalletTemplate.tsx
@@ -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 {
+ onWithdraw?: (amount: number) => void;
+ onExport?: () => void;
+ mutationLoading?: boolean;
+ transactions: any[];
+}
+
+export function LeagueWalletTemplate({ viewData, onExport, transactions }: LeagueWalletTemplateProps) {
+ return (
+
+
+ {/* Header */}
+
+
+ League Wallet
+ Manage your league's finances and payouts
+
+
+
+
+ Export
+
+
+
+
+ {}} // 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/templates/LeaguesTemplate.tsx b/apps/website/templates/LeaguesTemplate.tsx
new file mode 100644
index 000000000..11e9bc8e5
--- /dev/null
+++ b/apps/website/templates/LeaguesTemplate.tsx
@@ -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 {
+ 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 (
+
+
+ {/* 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
+
+
+
+
+
+ Create League
+
+
+
+
+
+ {/* Search & Filters */}
+
+ ) => onSearchChange(e.target.value)}
+ icon={}
+ />
+
+
+ {categories.map((category) => {
+ const isActive = activeCategory === category.id;
+ const CategoryIcon = category.icon;
+ return (
+ onCategoryChange(category.id)}
+ variant={isActive ? 'primary' : 'secondary'}
+ size="sm"
+ >
+
+
+
+
+ {category.label}
+
+
+ );
+ })}
+
+
+
+ {/* Grid */}
+
+ {filteredLeagues.length > 0 ? (
+
+ {filteredLeagues.map((league) => (
+ onLeagueClick(league.id)}
+ />
+ ))}
+
+ ) : (
+
+
+
+
+ No Leagues Found
+ Try adjusting your search or filters
+
+ Clear All Filters
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/website/templates/MediaTemplate.tsx b/apps/website/templates/MediaTemplate.tsx
index b8b09df34..bf685a5ea 100644
--- a/apps/website/templates/MediaTemplate.tsx
+++ b/apps/website/templates/MediaTemplate.tsx
@@ -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) {
const { assets, categories, title, description } = viewData;
return (
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/apps/website/templates/ProfileLiveryUploadTemplate.tsx b/apps/website/templates/ProfileLiveryUploadTemplate.tsx
new file mode 100644
index 000000000..6c508a7be
--- /dev/null
+++ b/apps/website/templates/ProfileLiveryUploadTemplate.tsx
@@ -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 {
+ selectedFile: File | null;
+ previewUrl: string | null;
+ isUploading: boolean;
+ onFilesSelected: (files: File[]) => void;
+ onUpload: () => void;
+}
+
+export function ProfileLiveryUploadTemplate({
+ selectedFile,
+ previewUrl,
+ isUploading,
+ onFilesSelected,
+ onUpload,
+}: ProfileLiveryUploadTemplateProps) {
+ return (
+
+
+
+ Upload livery
+
+ Upload your custom car livery. Supported formats: .png, .jpg, .tga
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ Upload Livery
+
+
+
+
+
+
+ {previewUrl ? (
+
+
+
+
+
+ ) : (
+
+
+ Select a file to see preview and details
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/website/templates/ProtestDetailTemplate.tsx b/apps/website/templates/ProtestDetailTemplate.tsx
new file mode 100644
index 000000000..00bc005a1
--- /dev/null
+++ b/apps/website/templates/ProtestDetailTemplate.tsx
@@ -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 {
+ 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 (
+
+
+
+ {/* 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 || protestDetail.incident?.description}
+
+ {(protest.comment || protestDetail.comment) && (
+
+ Additional details:
+ {protest.comment || protestDetail.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"
+ />
+
+
+
+ Comment
+
+
+
+
+
+ )}
+
+
+
+
+ {/* Right Sidebar - Actions */}
+
+
+ {isPending && (
+ <>
+ {/* Quick Actions */}
+
+
+ Actions
+
+
+
+
+
+ Request Defense
+
+
+
+ setShowDecisionPanel(!showDecisionPanel)}
+ >
+
+
+ Make Decision
+
+
+
+
+
+
+
+
+
+ {/* Decision Panel */}
+ {showDecisionPanel && (
+
+
+ Stewarding Decision
+
+ {/* Decision Selection */}
+
+
+ setDecision('uphold')}
+ fullWidth
+ >
+
+
+ Uphold
+
+
+
+
+ setDecision('dismiss')}
+ fullWidth
+ >
+
+
+ Dismiss
+
+
+
+
+
+ {/* Penalty Selection (if upholding) */}
+ {decision === 'uphold' && (
+
+ Penalty Type
+
+ {penaltyTypes.length === 0 ? (
+
+ Loading penalty types...
+
+ ) : (
+ <>
+
+ {penaltyTypes.map((penalty: any) => {
+ const Icon = penalty.icon;
+ const isSelected = penaltyType === penalty.type;
+ return (
+
+ {
+ setPenaltyType(penalty.type);
+ setPenaltyValue(penalty.defaultValue);
+ }}
+ fullWidth
+ title={penalty.description}
+ >
+
+
+
+ {penalty.label}
+
+
+
+
+ );
+ })}
+
+
+ {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 */}
+
+ {submitting ? 'Submitting...' : 'Submit Decision'}
+
+
+
+ )}
+ >
+ )}
+
+ {/* Already Resolved Info */}
+ {!isPending && (
+
+
+
+
+ Case Closed
+
+ {protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
+
+
+
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/website/templates/RacesAllTemplate.tsx b/apps/website/templates/RacesAllTemplate.tsx
index 2f9ac9306..f14c07c4b 100644
--- a/apps/website/templates/RacesAllTemplate.tsx
+++ b/apps/website/templates/RacesAllTemplate.tsx
@@ -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 {
races: RacesViewData['races'];
totalFilteredCount: number;
isLoading: boolean;
@@ -60,100 +57,67 @@ export function RacesAllTemplate({
setShowFilterModal,
onRaceClick,
}: RacesAllTemplateProps) {
- if (isLoading) {
- return (
-
-
-
-
- {[1, 2, 3, 4, 5].map(i => (
-
- ))}
-
-
-
- );
- }
-
+ // Note: Loading state is handled by StatefulPageWrapper in the client wrapper
+
return (
-
-
-
-
+ }
+ stats={
+ setShowFilterModal(true)}
+ />
+ }
+ pagination={
+
+ }
+ >
+
+ {races.length === 0 ? (
+
+ No races found matching your criteria.
+
+ ) : (
+ ({
+ id: race.id,
+ track: race.track,
+ car: race.car,
+ leagueName: race.leagueName,
+ time: race.timeLabel,
+ status: race.status as SessionStatus
+ }))}
+ onRaceClick={onRaceClick}
/>
+ )}
+
-
-
- Showing {totalFilteredCount} races
-
- 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
-
-
-
-
- {races.length === 0 ? (
-
- No races found matching your criteria.
-
- ) : (
- ({
- id: race.id,
- track: race.track,
- car: race.car,
- leagueName: race.leagueName,
- time: race.timeLabel,
- status: race.status as SessionStatus
- }))}
- onRaceClick={onRaceClick}
- />
- )}
-
-
-
-
- setShowFilterModal(false)}
- statusFilter={statusFilter}
- setStatusFilter={setStatusFilter}
- leagueFilter={leagueFilter}
- setLeagueFilter={setLeagueFilter}
- timeFilter="all"
- setTimeFilter={() => {}}
- searchQuery={searchQuery}
- setSearchQuery={setSearchQuery}
- leagues={viewData.leagues}
- showSearch={true}
- showTimeFilter={false}
- />
-
-
-
+ setShowFilterModal(false)}
+ statusFilter={statusFilter}
+ setStatusFilter={setStatusFilter}
+ leagueFilter={leagueFilter}
+ setLeagueFilter={setLeagueFilter}
+ timeFilter="all"
+ setTimeFilter={() => {}}
+ searchQuery={searchQuery}
+ setSearchQuery={setSearchQuery}
+ leagues={viewData.leagues}
+ showSearch={true}
+ showTimeFilter={false}
+ />
+
);
}
diff --git a/apps/website/templates/SponsorLeagueDetailTemplate.tsx b/apps/website/templates/SponsorLeagueDetailTemplate.tsx
index 1564c6876..b4b623af8 100644
--- a/apps/website/templates/SponsorLeagueDetailTemplate.tsx
+++ b/apps/website/templates/SponsorLeagueDetailTemplate.tsx
@@ -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 {
activeTab: SponsorLeagueDetailTab;
setActiveTab: (tab: SponsorLeagueDetailTab) => void;
selectedTier: 'main' | 'secondary';
@@ -171,253 +174,258 @@ export function SponsorLeagueDetailTemplate({
];
return (
-
-
- {/* Breadcrumb */}
-
-
-
- Dashboard
-
- /
-
- Leagues
-
- /
- {league.name}
-
-
+
+
+
+ {/* Breadcrumb */}
+
+
+
+ Dashboard
+
+ /
+
+ Leagues
+
+ /
+ {league.name}
+
+
- {/* Header */}
- console.log('Refresh')}
- />
+ {/* Header */}
+ console.log('Refresh')}
+ />
- {/* Quick Stats */}
-
+ {/* Quick Stats */}
+
- {/* Tabs */}
-
-
- {(['overview', 'drivers', 'races', 'sponsor'] as const).map((tab) => (
- 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'}
- >
-
- {tab === 'sponsor' ? '🎯 Become a Sponsor' : tab}
-
-
- ))}
-
-
+ {/* Tabs */}
+
+
+ {(['overview', 'drivers', 'races', 'sponsor'] as const).map((tab) => (
+ 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'}
+ >
+
+ {tab === 'sponsor' ? '🎯 Become a Sponsor' : tab}
+
+
+ ))}
+
+
- {/* Tab Content */}
- {activeTab === 'overview' && (
-
-
-
- }>
- League Information
-
-
-
-
-
-
-
-
-
-
-
-
-
- }>
- Sponsorship Value
-
-
-
-
-
-
-
-
-
-
-
- {league.nextRace && (
-
-
-
- }>
- Next Race
+ {/* Tab Content */}
+ {activeTab === 'overview' && (
+
+
+
+
+
+
+ League Information
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }>
+ Sponsorship Value
+
+
+
+
+
+
+
+
+
+
+
+ {league.nextRace && (
+
+
+
+ }>
+ Next Race
+
+
+
+
+
+
+
+
+
+ {league.nextRace.name}
+ {league.nextRace.date}
+
+
+
+ View Schedule
+
+
+
+
+
+ )}
+
+ )}
+
+ {activeTab === 'drivers' && (
+
+
+ Championship Standings
+ Top drivers carrying sponsor branding
+
+
+ {viewData.drivers.map((driver, index) => (
+
+
+
+
+ {driver.position}
-
- {league.nextRace.name}
- {league.nextRace.date}
-
-
-
-
-
-
-
- )}
-
- )}
+
+ {driver.name}
+ {driver.team} • {driver.country}
+
+
+
+
+ {driver.races}
+ races
+
+
+ {driver.formattedImpressions}
+ views
+
+
+
+
+ ))}
+
+
+ )}
- {activeTab === 'drivers' && (
-
-
- Championship Standings
- Top drivers carrying sponsor branding
-
-
- {viewData.drivers.map((driver, index) => (
-
-
-
-
- {driver.position}
-
-
- {driver.name}
- {driver.team} • {driver.country}
-
-
-
-
- {driver.races}
- races
-
-
- {driver.formattedImpressions}
- views
-
-
-
-
- ))}
-
-
- )}
+ {activeTab === 'races' && (
+
+
+ Race Calendar
+ Season schedule with view statistics
+
+
+ {viewData.races.map((race, index) => (
+
+
+
+
+
+ {race.name}
+ {race.formattedDate}
+
+
+
+ {race.status === 'completed' ? (
+
+ {race.views.toLocaleString()}
+ views
+
+ ) : (
+
+ )}
+
+
+
+ ))}
+
+
+ )}
- {activeTab === 'races' && (
-
-
- Race Calendar
- Season schedule with view statistics
-
-
- {viewData.races.map((race, index) => (
-
-
-
-
-
- {race.name}
- {race.formattedDate}
-
-
-
- {race.status === 'completed' ? (
-
- {race.views.toLocaleString()}
- views
-
- ) : (
-
- )}
-
-
-
- ))}
-
-
- )}
-
- {activeTab === 'sponsor' && (
-
-
- setSelectedTier(id as 'main' | 'secondary')}
- />
-
-
-
-
-
+
+ setSelectedTier(id as 'main' | 'secondary')}
/>
-
-
-
- }>
- Sponsorship Summary
-
-
+
+
+
+
+
-
-
-
-
-
-
- Total (excl. VAT)
-
- ${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * (1 + siteConfig.fees.platformFeePercent / 100)).toFixed(2)}
-
-
-
-
+
+
+ }>
+ Sponsorship Summary
+
+
+
+
+
+
+
+
+
+ Total (excl. VAT)
+
+ ${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * (1 + siteConfig.fees.platformFeePercent / 100)).toFixed(2)}
+
+
+
+
-
- {siteConfig.vat.notice}
-
+
+ {siteConfig.vat.notice}
+
-
- }>
- Request Sponsorship
-
- }>
- Download Info Pack
-
-
-
-
-
-
- )}
-
-
+
+ }>
+ Request Sponsorship
+
+ }>
+ Download Info Pack
+
+
+
+
+
+
+ )}
+
+
+
);
}
function InfoRow({ label, value, color = 'text-white', last }: { label: string, value: string | number, color?: string, last?: boolean }) {
return (
-
-
- {label}
- {value}
-
-
+
+
+ {label}
+ {value}
+
+
);
}
diff --git a/apps/website/templates/StewardingTemplate.tsx b/apps/website/templates/StewardingTemplate.tsx
new file mode 100644
index 000000000..30bb7ecc2
--- /dev/null
+++ b/apps/website/templates/StewardingTemplate.tsx
@@ -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 {
+ 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;
+ onRejectProtest: (protestId: string, stewardNotes: string) => Promise;
+ 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 (
+
+
+
+ {/* Tab navigation */}
+
+
+
+ onTabChange('pending')}
+ rounded={false}
+ >
+
+ Pending Protests
+ {viewData.totalPending > 0 && (
+
+ {viewData.totalPending}
+
+ )}
+
+
+
+
+ onTabChange('history')}
+ rounded={false}
+ >
+ History
+
+
+
+
+
+ {/* Content */}
+ {activeTab === 'pending' ? (
+
+ ) : (
+
+
+
+
+
+ )}
+
+ {activeTab === 'history' && (
+ setShowQuickPenaltyModal(true)} />
+ )}
+
+ {selectedProtest && (
+
+ )}
+
+ {showQuickPenaltyModal && (
+ ({
+ 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) }))}
+ />
+ )}
+
+ );
+}
diff --git a/apps/website/templates/TeamsTemplate.tsx b/apps/website/templates/TeamsTemplate.tsx
index b731ee000..de5bdbde3 100644
--- a/apps/website/templates/TeamsTemplate.tsx
+++ b/apps/website/templates/TeamsTemplate.tsx
@@ -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 {
onTeamClick?: (teamId: string) => void;
onViewFullLeaderboard: () => void;
onCreateTeam: () => void;
@@ -23,58 +20,44 @@ export function TeamsTemplate({ viewData, onTeamClick, onViewFullLeaderboard, on
const { teams } = viewData;
return (
-
-
-
-
+
+
-
-
-
- Active Rosters
-
-
- {teams.length > 0 ? (
-
- {teams.map((team) => (
- onTeamClick?.(team.teamId)}
- />
- ))}
-
- ) : (
-
+ {teams.length > 0 ? (
+
+ {teams.map((team) => (
+ onTeamClick?.(team.teamId)}
/>
- )}
-
+ ))}
+
+ ) : (
+
+ )}
+
- {/* Team Leaderboard Preview */}
-
-
-
- Global Standings
-
- onTeamClick?.(id)}
- onViewFullLeaderboard={onViewFullLeaderboard}
- />
-
-
-
-
+
+ onTeamClick?.(id)}
+ onViewFullLeaderboard={onViewFullLeaderboard}
+ />
+
+
);
}
diff --git a/apps/website/templates/auth/LoginLoadingTemplate.tsx b/apps/website/templates/auth/LoginLoadingTemplate.tsx
new file mode 100644
index 000000000..cfa1fa612
--- /dev/null
+++ b/apps/website/templates/auth/LoginLoadingTemplate.tsx
@@ -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) {
+ return ;
+}
diff --git a/apps/website/templates/shared/StatusTemplates.tsx b/apps/website/templates/shared/StatusTemplates.tsx
new file mode 100644
index 000000000..27026d0cf
--- /dev/null
+++ b/apps/website/templates/shared/StatusTemplates.tsx
@@ -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 {
+ message?: string;
+ description?: string;
+}
+
+export function ErrorTemplate({ message = "An error occurred", description = "Please try again later" }: ErrorTemplateProps) {
+ return (
+
+
+ {message}
+ {description}
+
+
+ );
+}
+
+interface EmptyTemplateProps extends TemplateProps {
+ title: string;
+ description: string;
+}
+
+export function EmptyTemplate({ title, description }: EmptyTemplateProps) {
+ return (
+
+
+ {title}
+ {description}
+
+
+ );
+}
diff --git a/apps/website/ui/GridItem.tsx b/apps/website/ui/GridItem.tsx
new file mode 100644
index 000000000..ef39d76cb
--- /dev/null
+++ b/apps/website/ui/GridItem.tsx
@@ -0,0 +1,35 @@
+import React, { ReactNode, ElementType, forwardRef, ForwardedRef } from 'react';
+import { Box, BoxProps, ResponsiveValue } from './Box';
+
+export interface GridItemProps extends BoxProps {
+ as?: T;
+ children?: ReactNode;
+ colSpan?: number | ResponsiveValue;
+ rowSpan?: number | ResponsiveValue;
+}
+
+export const GridItem = forwardRef((
+ {
+ children,
+ colSpan,
+ rowSpan,
+ as,
+ ...props
+ }: GridItemProps,
+ ref: ForwardedRef
+) => {
+ return (
+
+ {children}
+
+ );
+});
+
+GridItem.displayName = 'GridItem';
diff --git a/apps/website/ui/StatusIndicator.tsx b/apps/website/ui/StatusIndicator.tsx
index 95283e56c..71917b5f5 100644
--- a/apps/website/ui/StatusIndicator.tsx
+++ b/apps/website/ui/StatusIndicator.tsx
@@ -21,7 +21,7 @@ export const StatusIndicator = ({
subLabel,
size = 'md',
icon
-}:) => {
+}: StatusIndicatorProps) => {
const activeStatus = (status || variant || 'pending') as any;
const configMap: any = {