-
-
-
-
-
- {isConnectivity ? 'Connection Issue' : 'Something Went Wrong'}
-
-
Error {error.context.statusCode || 'N/A'}
-
+
+ {/* Icon */}
+
- {/* Body */}
-
-
{userMessage}
-
- {/* Technical Details (Development Only) */}
- {isDev && (
-
- Technical Details
-
-
Type: {error.type}
-
Endpoint: {error.context.endpoint || 'N/A'}
- {error.context.statusCode &&
Status: {error.context.statusCode}
}
- {error.context.retryCount !== undefined && (
-
Retries: {error.context.retryCount}
- )}
- {error.context.wasRetry &&
Was Retry: true
}
-
-
+ {/* Title */}
+
+ {errorInfo.title}
+
+
+ {/* Message */}
+
+ {errorInfo.message}
+
+
+ {/* API Error Details */}
+ {errorInfo.isApiError && errorInfo.statusCode && (
+
+ HTTP {errorInfo.statusCode}
+ {errorInfo.details && !hideTechnicalDetails && (
+ - {errorInfo.details}
+ )}
+
+ )}
+
+ {/* Actions */}
+ {defaultActions.length > 0 && (
+
+ {defaultActions.map((action, index) => (
+
+ {action.label}
+
+ ))}
+
+ )}
+
+ {/* Technical Details Toggle (for development) */}
+ {!hideTechnicalDetails && process.env.NODE_ENV === 'development' && error.stack && (
+
+
+ Technical Details
+
+
+ {error.stack}
+
+
+ )}
+
+
+ );
+
+ case 'card':
+ return (
+
+
+ {/* Icon */}
+
+
+ {/* Content */}
+
+
+ {errorInfo.title}
+
+
+ {errorInfo.message}
+
+
+ {/* API Error Details */}
+ {errorInfo.isApiError && errorInfo.statusCode && (
+
+ HTTP {errorInfo.statusCode}
+ {errorInfo.details && !hideTechnicalDetails && ` - ${errorInfo.details}`}
+
)}
- {/* Action Buttons */}
-
- {isRetryable && onRetry && (
-
- {isRetrying ? (
- <>
-
- Retrying...
- >
- ) : (
- <>
-
- Try Again
- >
- )}
-
- )}
-
- {showNavigation && (
-
-
0 && (
+
+ {defaultActions.map((action, index) => (
+
-
- Go Back
-
-
-
-
- Home
-
-
- )}
-
- {/* Custom Actions */}
- {actions.length > 0 && (
-
- {actions.map((action, index) => (
-
- {action.icon && }
- {action.label}
-
- ))}
-
- )}
-
-
-
- {/* Footer */}
-
+ )}
@@ -199,145 +186,20 @@ export function ErrorDisplay({
case 'inline':
return (
-
-
-
-
{userMessage}
- {isDev && (
-
- [{error.type}] {error.context.statusCode || 'N/A'}
-
- )}
-
- {isRetryable && onRetry && (
-
- {isRetrying ? 'Retrying...' : 'Retry'}
-
- )}
- {actions.map((action, index) => (
-
- {action.label}
-
- ))}
-
-
+
+
{errorInfo.message}
+ {onRetry && showRetry && (
-
+ Retry
-
-
- );
-
- case 'card':
- return (
-
-
-
-
{userMessage}
- {isDev && (
-
- {error.type} | Status: {error.context.statusCode || 'N/A'}
-
- )}
-
- {isRetryable && onRetry && (
-
-
- {isRetrying ? 'Retrying' : 'Retry'}
-
- )}
- {actions.map((action, index) => (
-
- {action.label}
-
- ))}
-
-
-
- );
-
- case 'toast':
- return (
-
-
-
-
{userMessage}
- {isDev && (
-
- [{error.type}] {error.context.statusCode || 'N/A'}
-
- )}
-
- {isRetryable && onRetry && (
-
- {isRetrying ? '...' : 'Retry'}
-
- )}
- {actions.slice(0, 2).map((action, index) => (
-
- {action.label}
-
- ))}
-
-
-
-
-
+ )}
);
@@ -347,57 +209,44 @@ export function ErrorDisplay({
}
/**
- * Convenience component for full-screen error display
+ * Convenience component for API error display
*/
-export function FullScreenError({ error, onRetry, ...props }: ErrorDisplayProps) {
+export function ApiErrorDisplay({
+ error,
+ onRetry,
+ variant = 'full-screen',
+ hideTechnicalDetails = false,
+}: {
+ error: ApiError;
+ onRetry?: () => void;
+ variant?: 'full-screen' | 'card' | 'inline';
+ hideTechnicalDetails?: boolean;
+}) {
return (
);
}
/**
- * Convenience component for inline error display
+ * Convenience component for network error display
*/
-export function InlineError({ error, onRetry, ...props }: ErrorDisplayProps) {
+export function NetworkErrorDisplay({
+ onRetry,
+ variant = 'full-screen',
+}: {
+ onRetry?: () => void;
+ variant?: 'full-screen' | 'card' | 'inline';
+}) {
return (
- );
-}
-
-/**
- * Convenience component for card error display
- */
-export function CardError({ error, onRetry, ...props }: ErrorDisplayProps) {
- return (
-
- );
-}
-
-/**
- * Convenience component for toast error display
- */
-export function ToastError({ error, onRetry, ...props }: ErrorDisplayProps) {
- return (
-
);
}
\ No newline at end of file
diff --git a/apps/website/components/shared/state/PageWrapper.tsx b/apps/website/components/shared/state/PageWrapper.tsx
new file mode 100644
index 000000000..fed9c9171
--- /dev/null
+++ b/apps/website/components/shared/state/PageWrapper.tsx
@@ -0,0 +1,276 @@
+import React, { ReactNode } from 'react';
+import { ApiError } from '@/lib/api/base/ApiError';
+import { LoadingWrapper } from './LoadingWrapper';
+import { ErrorDisplay } from './ErrorDisplay';
+import { EmptyState } from './EmptyState';
+import { Inbox, List, LucideIcon } from 'lucide-react';
+
+// ==================== PAGEWRAPPER TYPES ====================
+
+export interface PageWrapperLoadingConfig {
+ variant?: 'skeleton' | 'full-screen';
+ message?: string;
+}
+
+export interface PageWrapperErrorConfig {
+ variant?: 'full-screen' | 'card';
+ card?: {
+ title?: string;
+ description?: string;
+ };
+}
+
+export interface PageWrapperEmptyConfig {
+ icon?: LucideIcon;
+ title?: string;
+ description?: string;
+ action?: {
+ label: string;
+ onClick: () => void;
+ };
+}
+
+export interface PageWrapperProps
{
+ /** Data to be rendered */
+ data: TData | undefined;
+ /** Loading state (default: false) */
+ isLoading?: boolean;
+ /** Error state (default: null) */
+ error?: Error | null;
+ /** Retry function for errors */
+ retry?: () => void;
+ /** Template component that receives the data */
+ Template: React.ComponentType<{ data: TData }>;
+ /** Loading configuration */
+ loading?: PageWrapperLoadingConfig;
+ /** Error configuration */
+ errorConfig?: PageWrapperErrorConfig;
+ /** Empty configuration */
+ empty?: PageWrapperEmptyConfig;
+ /** Children for flexible content rendering */
+ children?: ReactNode;
+ /** Additional CSS classes */
+ className?: string;
+}
+
+/**
+ * PageWrapper Component
+ *
+ * A comprehensive wrapper component that handles all page states:
+ * - Loading states (skeleton or full-screen)
+ * - Error states (full-screen or card)
+ * - Empty states (with icon, title, description, and action)
+ * - Success state (renders Template component with data)
+ * - Flexible children support for custom content
+ *
+ * Usage Example:
+ * ```typescript
+ *
+ *
+ *
+ * ```
+ */
+export function PageWrapper({
+ data,
+ isLoading = false,
+ error = null,
+ retry,
+ Template,
+ loading,
+ errorConfig,
+ empty,
+ children,
+ className = '',
+}: PageWrapperProps) {
+ // Priority order: Loading > Error > Empty > Success
+
+ // 1. Loading State
+ if (isLoading) {
+ const loadingVariant = loading?.variant || 'skeleton';
+ const loadingMessage = loading?.message || 'Loading...';
+
+ if (loadingVariant === 'full-screen') {
+ return (
+
+ );
+ }
+
+ // Default to skeleton
+ return (
+
+
+ {children}
+
+ );
+ }
+
+ // 2. Error State
+ if (error) {
+ const errorVariant = errorConfig?.variant || 'full-screen';
+
+ if (errorVariant === 'card') {
+ const cardTitle = errorConfig?.card?.title || 'Error';
+ const cardDescription = errorConfig?.card?.description || 'Something went wrong';
+
+ return (
+
+
+ {children}
+
+ );
+ }
+
+ // Default to full-screen
+ return (
+
+ );
+ }
+
+ // 3. Empty State
+ if (!data || (Array.isArray(data) && data.length === 0)) {
+ if (empty) {
+ const Icon = empty.icon;
+ const hasAction = empty.action && retry;
+
+ return (
+
+
+ {children}
+
+ );
+ }
+
+ // If no empty config provided but data is empty, show nothing
+ return (
+
+ {children}
+
+ );
+ }
+
+ // 4. Success State - Render Template with data
+ return (
+
+
+ {children}
+
+ );
+}
+
+/**
+ * Convenience component for list data with automatic empty state handling
+ */
+export function ListPageWrapper({
+ data,
+ isLoading = false,
+ error = null,
+ retry,
+ Template,
+ loading,
+ errorConfig,
+ empty,
+ children,
+ className = '',
+}: PageWrapperProps) {
+ const listEmpty = empty || {
+ icon: List,
+ title: 'No items found',
+ description: 'This list is currently empty',
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Convenience component for detail pages with enhanced error handling
+ */
+export function DetailPageWrapper({
+ data,
+ isLoading = false,
+ error = null,
+ retry,
+ Template,
+ loading,
+ errorConfig,
+ empty,
+ children,
+ className = '',
+ onBack,
+ onRefresh,
+}: PageWrapperProps & {
+ onBack?: () => void;
+ onRefresh?: () => void;
+}) {
+ // Create enhanced error config with additional actions
+ const enhancedErrorConfig: PageWrapperErrorConfig = {
+ ...errorConfig,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/shared/state/StateContainer.tsx b/apps/website/components/shared/state/StateContainer.tsx
index 760f9e261..c8b9938c3 100644
--- a/apps/website/components/shared/state/StateContainer.tsx
+++ b/apps/website/components/shared/state/StateContainer.tsx
@@ -5,6 +5,7 @@ import { StateContainerProps, StateContainerConfig } from './types';
import { LoadingWrapper } from './LoadingWrapper';
import { ErrorDisplay } from './ErrorDisplay';
import { EmptyState } from './EmptyState';
+import { Inbox, AlertCircle, Grid, List } from 'lucide-react';
/**
* StateContainer Component
@@ -121,7 +122,7 @@ export function StateContainer({
return (
@@ -133,7 +134,7 @@ export function StateContainer
({
({
return (
@@ -187,7 +188,7 @@ export function ListStateContainer
({
const listConfig: StateContainerConfig = {
...config,
empty: emptyConfig || {
- icon: require('lucide-react').List,
+ icon: List,
title: 'No items found',
description: 'This list is currently empty',
},
@@ -367,7 +368,7 @@ export function GridStateContainer({
},
...config,
empty: emptyConfig || {
- icon: require('lucide-react').Grid,
+ icon: Grid,
title: 'No items to display',
description: 'Try adjusting your filters or search',
},
diff --git a/apps/website/components/shared/state/StatefulPageWrapper.tsx b/apps/website/components/shared/state/StatefulPageWrapper.tsx
new file mode 100644
index 000000000..33e1926ea
--- /dev/null
+++ b/apps/website/components/shared/state/StatefulPageWrapper.tsx
@@ -0,0 +1,61 @@
+'use client';
+
+import React from 'react';
+import { PageWrapper, PageWrapperProps } from './PageWrapper';
+
+/**
+ * Stateful Page Wrapper - CLIENT SIDE ONLY
+ * Adds loading/error state management for client-side fetching
+ *
+ * Usage:
+ * ```typescript
+ * 'use client';
+ *
+ * export default function ProfilePage() {
+ * const { data, isLoading, error, refetch } = usePageData(...);
+ *
+ * return (
+ *
+ * );
+ * }
+ * ```
+ */
+export function StatefulPageWrapper({
+ data,
+ isLoading = false,
+ error = null,
+ retry,
+ Template,
+ loading,
+ errorConfig,
+ empty,
+ children,
+ className = '',
+}: PageWrapperProps) {
+ // Same implementation but with 'use client' for CSR-specific features
+ return (
+
+ {children}
+
+ );
+}
+
+// Re-export types for convenience
+export type { PageWrapperProps, PageWrapperLoadingConfig, PageWrapperErrorConfig, PageWrapperEmptyConfig } from './PageWrapper';
\ No newline at end of file
diff --git a/apps/website/components/shared/state/types.ts b/apps/website/components/shared/state/types.ts
index b9d233add..ad4e248a9 100644
--- a/apps/website/components/shared/state/types.ts
+++ b/apps/website/components/shared/state/types.ts
@@ -1,7 +1,7 @@
'use client';
-import { LucideIcon } from 'lucide-react';
import { ReactNode } from 'react';
+import { LucideIcon } from 'lucide-react';
import { ApiError } from '@/lib/api/base/ApiError';
// ==================== EMPTY STATE TYPES ====================
@@ -9,12 +9,12 @@ import { ApiError } from '@/lib/api/base/ApiError';
export interface EmptyStateAction {
label: string;
onClick: () => void;
- icon?: LucideIcon;
variant?: 'primary' | 'secondary' | 'danger' | 'race-performance' | 'race-final';
+ icon?: LucideIcon;
}
export interface EmptyStateProps {
- icon: LucideIcon;
+ icon?: LucideIcon;
title: string;
description?: string;
action?: EmptyStateAction;
@@ -24,9 +24,9 @@ export interface EmptyStateProps {
ariaLabel?: string;
}
-// ==================== LOADING STATE TYPES ====================
+// ==================== LOADING WRAPPER TYPES ====================
-export interface LoadingCardConfig {
+export interface LoadingWrapperCardConfig {
count?: number;
height?: string;
className?: string;
@@ -38,67 +38,58 @@ export interface LoadingWrapperProps {
className?: string;
size?: 'sm' | 'md' | 'lg';
skeletonCount?: number;
- cardConfig?: LoadingCardConfig;
+ cardConfig?: LoadingWrapperCardConfig;
ariaLabel?: string;
}
-// ==================== ERROR STATE TYPES ====================
+// ==================== ERROR DISPLAY TYPES ====================
-export interface ErrorAction {
+export interface ErrorDisplayAction {
label: string;
onClick: () => void;
- variant?: 'primary' | 'secondary' | 'danger' | 'race-performance' | 'race-final';
- icon?: LucideIcon;
- disabled?: boolean;
+ variant: 'primary' | 'secondary' | 'outline' | 'ghost';
}
export interface ErrorDisplayProps {
- error: ApiError;
+ error: ApiError | Error;
onRetry?: () => void;
- variant?: 'full-screen' | 'inline' | 'card' | 'toast';
+ variant?: 'full-screen' | 'card' | 'inline';
+ actions?: ErrorDisplayAction[];
showRetry?: boolean;
showNavigation?: boolean;
- actions?: ErrorAction[];
- className?: string;
hideTechnicalDetails?: boolean;
- ariaLabel?: string;
+ className?: string;
}
// ==================== STATE CONTAINER TYPES ====================
export interface StateContainerConfig {
- loading?: {
- variant?: LoadingWrapperProps['variant'];
- message?: string;
- size?: LoadingWrapperProps['size'];
- skeletonCount?: number;
- };
+ loading?: Pick;
error?: {
- variant?: ErrorDisplayProps['variant'];
+ variant?: 'full-screen' | 'card' | 'inline';
+ actions?: ErrorDisplayAction[];
showRetry?: boolean;
showNavigation?: boolean;
hideTechnicalDetails?: boolean;
- actions?: ErrorAction[];
};
empty?: {
- icon: LucideIcon;
- title: string;
+ icon?: LucideIcon;
+ title?: string;
description?: string;
action?: EmptyStateAction;
- illustration?: EmptyStateProps['illustration'];
};
customRender?: {
loading?: () => ReactNode;
- error?: (error: ApiError) => ReactNode;
+ error?: (error: Error) => ReactNode;
empty?: () => ReactNode;
};
}
export interface StateContainerProps {
- data: T | null | undefined;
- isLoading: boolean;
- error: ApiError | null;
- retry: () => void;
+ data: T | undefined;
+ isLoading?: boolean;
+ error?: Error | null;
+ retry?: () => void;
children: (data: T) => ReactNode;
config?: StateContainerConfig;
className?: string;
@@ -106,11 +97,40 @@ export interface StateContainerProps {
isEmpty?: (data: T) => boolean;
}
-// ==================== CONVENIENCE PROP TYPES ====================
+// ==================== PAGE WRAPPER TYPES ====================
-// For components that only need specific subsets of props
-export type MinimalEmptyStateProps = Omit;
-export type MinimalLoadingProps = Pick;
-export type InlineLoadingProps = Pick;
-export type SkeletonLoadingProps = Pick;
-export type CardLoadingProps = Pick;
\ No newline at end of file
+export interface PageWrapperLoadingConfig {
+ variant?: 'skeleton' | 'full-screen';
+ message?: string;
+}
+
+export interface PageWrapperErrorConfig {
+ variant?: 'full-screen' | 'card';
+ card?: {
+ title?: string;
+ description?: string;
+ };
+}
+
+export interface PageWrapperEmptyConfig {
+ icon?: LucideIcon;
+ title?: string;
+ description?: string;
+ action?: {
+ label: string;
+ onClick: () => void;
+ };
+}
+
+export interface PageWrapperProps {
+ data: TData | undefined;
+ isLoading?: boolean;
+ error?: Error | null;
+ retry?: () => void;
+ Template: React.ComponentType<{ data: TData }>;
+ loading?: PageWrapperLoadingConfig;
+ errorConfig?: PageWrapperErrorConfig;
+ empty?: PageWrapperEmptyConfig;
+ children?: ReactNode;
+ className?: string;
+}
\ No newline at end of file
diff --git a/apps/website/hooks/dashboard/useDashboardOverview.ts b/apps/website/hooks/dashboard/useDashboardOverview.ts
deleted file mode 100644
index 190df6eb4..000000000
--- a/apps/website/hooks/dashboard/useDashboardOverview.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { useQuery, UseQueryOptions } from '@tanstack/react-query';
-import { useInject } from '@/lib/di/hooks/useInject';
-import { DASHBOARD_SERVICE_TOKEN } from '@/lib/di/tokens';
-import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
-import { ApiError } from '@/lib/api/base/ApiError';
-import { DashboardOverviewViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
-
-export function useDashboardOverview(
- options?: Omit, 'queryKey' | 'queryFn'>
-) {
- const dashboardService = useInject(DASHBOARD_SERVICE_TOKEN);
-
- const queryResult = useQuery({
- queryKey: ['dashboardOverview'],
- queryFn: () => dashboardService.getDashboardOverview(),
- ...options,
- });
-
- return enhanceQueryResult(queryResult);
-}
\ No newline at end of file
diff --git a/apps/website/hooks/driver/index.ts b/apps/website/hooks/driver/index.ts
index 778e86071..aaccd4da2 100644
--- a/apps/website/hooks/driver/index.ts
+++ b/apps/website/hooks/driver/index.ts
@@ -1,5 +1,5 @@
export { useCurrentDriver } from './useCurrentDriver';
-export { useDriverLeaderboard } from './useDriverLeaderboard';
+export { useDriverLeaderboard } from '@/lib/hooks/useDriverLeaderboard';
export { useDriverProfile } from './useDriverProfile';
export { useUpdateDriverProfile } from './useUpdateDriverProfile';
export { useCreateDriver } from './useCreateDriver';
diff --git a/apps/website/hooks/driver/useDriverLeaderboard.ts b/apps/website/hooks/driver/useDriverLeaderboard.ts
deleted file mode 100644
index 06fb657e9..000000000
--- a/apps/website/hooks/driver/useDriverLeaderboard.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { useQuery } from '@tanstack/react-query';
-import { useInject } from '@/lib/di/hooks/useInject';
-import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
-import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
-
-export function useDriverLeaderboard() {
- const driverService = useInject(DRIVER_SERVICE_TOKEN);
-
- const queryResult = useQuery({
- queryKey: ['driverLeaderboard'],
- queryFn: () => driverService.getDriverLeaderboard(),
- });
-
- return enhanceQueryResult(queryResult);
-}
diff --git a/apps/website/hooks/driver/useDriverProfilePageData.ts b/apps/website/hooks/driver/useDriverProfilePageData.ts
new file mode 100644
index 000000000..af22d8abb
--- /dev/null
+++ b/apps/website/hooks/driver/useDriverProfilePageData.ts
@@ -0,0 +1,47 @@
+import { usePageDataMultiple } from '@/lib/page/usePageData';
+import { useInject } from '@/lib/di/hooks/useInject';
+import { DRIVER_SERVICE_TOKEN, TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
+
+export function useDriverProfilePageData(driverId: string) {
+ const driverService = useInject(DRIVER_SERVICE_TOKEN);
+ const teamService = useInject(TEAM_SERVICE_TOKEN);
+
+ return usePageDataMultiple({
+ driverProfile: {
+ queryKey: ['driverProfile', driverId],
+ queryFn: () => driverService.getDriverProfile(driverId),
+ enabled: !!driverId,
+ },
+ teamMemberships: {
+ queryKey: ['teamMemberships', driverId],
+ queryFn: async () => {
+ if (!driverId) return [];
+
+ const allTeams = await teamService.getAllTeams();
+ let teamMemberships: Array<{
+ team: { id: string; name: string };
+ role: string;
+ joinedAt: Date;
+ }> = [];
+
+ for (const team of allTeams) {
+ const teamMembers = await teamService.getTeamMembers(team.id, driverId, '');
+ const membership = teamMembers?.find(member => member.driverId === driverId);
+ if (membership) {
+ teamMemberships.push({
+ team: {
+ id: team.id,
+ name: team.name,
+ },
+ role: membership.role,
+ joinedAt: new Date(membership.joinedAt),
+ });
+ }
+ }
+
+ return teamMemberships;
+ },
+ enabled: !!driverId,
+ },
+ });
+}
\ No newline at end of file
diff --git a/apps/website/hooks/league/useAllLeaguesWithSponsors.ts b/apps/website/hooks/league/useAllLeaguesWithSponsors.ts
deleted file mode 100644
index afbd25c04..000000000
--- a/apps/website/hooks/league/useAllLeaguesWithSponsors.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { useQuery } from '@tanstack/react-query';
-import { useInject } from '@/lib/di/hooks/useInject';
-import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
-import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
-
-export function useAllLeaguesWithSponsors() {
- const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
-
- const queryResult = useQuery({
- queryKey: ['allLeaguesWithSponsors'],
- queryFn: () => leagueService.getAllLeagues(),
- });
-
- return enhanceQueryResult(queryResult);
-}
\ No newline at end of file
diff --git a/apps/website/hooks/league/useLeagueDetailWithSponsors.ts b/apps/website/hooks/league/useLeagueDetailWithSponsors.ts
deleted file mode 100644
index c66ccc1d1..000000000
--- a/apps/website/hooks/league/useLeagueDetailWithSponsors.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { useQuery, UseQueryOptions } from '@tanstack/react-query';
-import { useInject } from '@/lib/di/hooks/useInject';
-import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
-import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
-import { ApiError } from '@/lib/api/base/ApiError';
-import type { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
-
-export function useLeagueDetailWithSponsors(
- leagueId: string,
- options?: Omit, 'queryKey' | 'queryFn'>
-) {
- const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
-
- const queryResult = useQuery({
- queryKey: ['leagueDetailWithSponsors', leagueId],
- queryFn: () => leagueService.getLeagueDetailPageData(leagueId),
- ...options,
- });
-
- return enhanceQueryResult(queryResult);
-}
diff --git a/apps/website/hooks/league/useLeagueScheduleAdminPageData.ts b/apps/website/hooks/league/useLeagueScheduleAdminPageData.ts
new file mode 100644
index 000000000..17592d603
--- /dev/null
+++ b/apps/website/hooks/league/useLeagueScheduleAdminPageData.ts
@@ -0,0 +1,38 @@
+import { usePageData } from '@/lib/page/usePageData';
+import { useInject } from '@/lib/di/hooks/useInject';
+import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
+import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
+
+export function useLeagueAdminStatus(leagueId: string, currentDriverId: string) {
+ const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
+
+ return usePageData({
+ queryKey: ['admin-check', leagueId, currentDriverId],
+ queryFn: async () => {
+ await leagueMembershipService.fetchLeagueMemberships(leagueId);
+ const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
+ return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
+ },
+ enabled: !!leagueId && !!currentDriverId,
+ });
+}
+
+export function useLeagueSeasons(leagueId: string, isAdmin: boolean) {
+ const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
+
+ return usePageData({
+ queryKey: ['leagueSeasons', leagueId],
+ queryFn: () => leagueService.getLeagueSeasonSummaries(leagueId),
+ enabled: !!leagueId && !!isAdmin,
+ });
+}
+
+export function useLeagueAdminSchedule(leagueId: string, selectedSeasonId: string, isAdmin: boolean) {
+ const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
+
+ return usePageData({
+ queryKey: ['adminSchedule', leagueId, selectedSeasonId],
+ queryFn: () => leagueService.getAdminSchedule(leagueId, selectedSeasonId),
+ enabled: !!leagueId && !!selectedSeasonId && !!isAdmin,
+ });
+}
\ No newline at end of file
diff --git a/apps/website/hooks/league/useLeagueSponsorshipsPageData.ts b/apps/website/hooks/league/useLeagueSponsorshipsPageData.ts
new file mode 100644
index 000000000..c7506465e
--- /dev/null
+++ b/apps/website/hooks/league/useLeagueSponsorshipsPageData.ts
@@ -0,0 +1,21 @@
+import { usePageDataMultiple } from '@/lib/page/usePageData';
+import { useInject } from '@/lib/di/hooks/useInject';
+import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
+
+export function useLeagueSponsorshipsPageData(leagueId: string, currentDriverId: string) {
+ const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
+ const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
+
+ return usePageDataMultiple({
+ league: {
+ queryKey: ['leagueDetail', leagueId, currentDriverId],
+ queryFn: () => leagueService.getLeagueDetail(leagueId, currentDriverId),
+ },
+ membership: {
+ queryKey: ['leagueMembership', leagueId, currentDriverId],
+ queryFn: () => leagueMembershipService.fetchLeagueMemberships(leagueId).then(() => {
+ return leagueMembershipService.getMembership(leagueId, currentDriverId);
+ }),
+ },
+ });
+}
\ No newline at end of file
diff --git a/apps/website/hooks/league/useLeagueStewardingMutations.ts b/apps/website/hooks/league/useLeagueStewardingMutations.ts
new file mode 100644
index 000000000..54a9f96e2
--- /dev/null
+++ b/apps/website/hooks/league/useLeagueStewardingMutations.ts
@@ -0,0 +1,46 @@
+import { usePageMutation } from '@/lib/page/usePageData';
+
+export function useLeagueStewardingMutations(onRefetch: () => void) {
+ const acceptProtestMutation = usePageMutation(
+ async (variables: { protestId: string; penaltyType: string; penaltyValue: number; stewardNotes: string; raceId: string; accusedDriverId: string; reason: string }) => {
+ // TODO: Implement protest review and penalty application
+ // await leagueStewardingService.reviewProtest({
+ // protestId: variables.protestId,
+ // stewardId: currentDriverId,
+ // decision: 'uphold',
+ // decisionNotes: variables.stewardNotes,
+ // });
+
+ // await leagueStewardingService.applyPenalty({
+ // raceId: variables.raceId,
+ // driverId: variables.accusedDriverId,
+ // stewardId: currentDriverId,
+ // type: variables.penaltyType,
+ // value: variables.penaltyValue,
+ // reason: variables.reason,
+ // protestId: variables.protestId,
+ // notes: variables.stewardNotes,
+ // });
+ },
+ {
+ onSuccess: () => onRefetch(),
+ }
+ );
+
+ const rejectProtestMutation = usePageMutation(
+ async (variables: { protestId: string; stewardNotes: string }) => {
+ // TODO: Implement protest rejection
+ // await leagueStewardingService.reviewProtest({
+ // protestId: variables.protestId,
+ // stewardId: currentDriverId,
+ // decision: 'dismiss',
+ // decisionNotes: variables.stewardNotes,
+ // });
+ },
+ {
+ onSuccess: () => onRefetch(),
+ }
+ );
+
+ return { acceptProtestMutation, rejectProtestMutation };
+}
\ No newline at end of file
diff --git a/apps/website/hooks/league/useLeagueWallet.ts b/apps/website/hooks/league/useLeagueWallet.ts
deleted file mode 100644
index aabf781c2..000000000
--- a/apps/website/hooks/league/useLeagueWallet.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { useQuery } from '@tanstack/react-query';
-import { useInject } from '@/lib/di/hooks/useInject';
-import { LEAGUE_WALLET_SERVICE_TOKEN } from '@/lib/di/tokens';
-import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
-
-export function useLeagueWallet(leagueId: string) {
- const leagueWalletService = useInject(LEAGUE_WALLET_SERVICE_TOKEN);
-
- const queryResult = useQuery({
- queryKey: ['leagueWallet', leagueId],
- queryFn: () => leagueWalletService.getWalletForLeague(leagueId),
- enabled: !!leagueId,
- });
-
- return enhanceQueryResult(queryResult);
-}
diff --git a/apps/website/hooks/league/useLeagueWalletPageData.ts b/apps/website/hooks/league/useLeagueWalletPageData.ts
new file mode 100644
index 000000000..def8c4683
--- /dev/null
+++ b/apps/website/hooks/league/useLeagueWalletPageData.ts
@@ -0,0 +1,47 @@
+import { usePageData, usePageMutation } from '@/lib/page/usePageData';
+import { useInject } from '@/lib/di/hooks/useInject';
+import { LEAGUE_WALLET_SERVICE_TOKEN } from '@/lib/di/tokens';
+
+export function useLeagueWalletPageData(leagueId: string) {
+ const leagueWalletService = useInject(LEAGUE_WALLET_SERVICE_TOKEN);
+
+ const queryResult = usePageData({
+ queryKey: ['leagueWallet', leagueId],
+ queryFn: () => leagueWalletService.getWalletForLeague(leagueId),
+ enabled: !!leagueId,
+ });
+
+ return queryResult;
+}
+
+export function useLeagueWalletWithdrawal(leagueId: string, data: any, refetch: () => void) {
+ const leagueWalletService = useInject(LEAGUE_WALLET_SERVICE_TOKEN);
+
+ const withdrawMutation = usePageMutation(
+ async ({ amount }: { amount: number }) => {
+ if (!data) throw new Error('Wallet data not available');
+
+ const result = await leagueWalletService.withdraw(
+ leagueId,
+ amount,
+ data.currency,
+ 'season-2', // Current active season
+ 'bank-account-***1234'
+ );
+
+ if (!result.success) {
+ throw new Error(result.message || 'Withdrawal failed');
+ }
+
+ return result;
+ },
+ {
+ onSuccess: () => {
+ // Refetch wallet data after successful withdrawal
+ refetch();
+ },
+ }
+ );
+
+ return withdrawMutation;
+}
\ No newline at end of file
diff --git a/apps/website/hooks/protest/useLeagueProtests.ts b/apps/website/hooks/protest/useLeagueProtests.ts
deleted file mode 100644
index 36d524445..000000000
--- a/apps/website/hooks/protest/useLeagueProtests.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { useQuery } from '@tanstack/react-query';
-import { useInject } from '@/lib/di/hooks/useInject';
-import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
-import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
-
-export function useLeagueProtests(leagueId: string) {
- const protestService = useInject(PROTEST_SERVICE_TOKEN);
-
- const queryResult = useQuery({
- queryKey: ['leagueProtests', leagueId],
- queryFn: () => protestService.getLeagueProtests(leagueId),
- enabled: !!leagueId,
- });
-
- return enhanceQueryResult(queryResult);
-}
diff --git a/apps/website/hooks/race/useAllRacesPageData.ts b/apps/website/hooks/race/useAllRacesPageData.ts
new file mode 100644
index 000000000..5e5100cb8
--- /dev/null
+++ b/apps/website/hooks/race/useAllRacesPageData.ts
@@ -0,0 +1,12 @@
+import { usePageData } from '@/lib/page/usePageData';
+import { useInject } from '@/lib/di/hooks/useInject';
+import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
+
+export function useAllRacesPageData() {
+ const raceService = useInject(RACE_SERVICE_TOKEN);
+
+ return usePageData({
+ queryKey: ['races', 'all'],
+ queryFn: () => raceService.getAllRacesPageData(),
+ });
+}
\ No newline at end of file
diff --git a/apps/website/hooks/race/useCancelRace.ts b/apps/website/hooks/race/useCancelRace.ts
deleted file mode 100644
index b4c864de4..000000000
--- a/apps/website/hooks/race/useCancelRace.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { useMutation, UseMutationOptions } from '@tanstack/react-query';
-import { useInject } from '@/lib/di/hooks/useInject';
-import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
-import { ApiError } from '@/lib/api/base/ApiError';
-
-export function useCancelRace(
- options?: Omit, 'mutationFn'>
-) {
- const raceService = useInject(RACE_SERVICE_TOKEN);
-
- return useMutation({
- mutationFn: (raceId) => raceService.cancelRace(raceId),
- ...options,
- });
-}
diff --git a/apps/website/hooks/race/useCompleteRace.ts b/apps/website/hooks/race/useCompleteRace.ts
deleted file mode 100644
index f30792b5f..000000000
--- a/apps/website/hooks/race/useCompleteRace.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { useMutation, UseMutationOptions } from '@tanstack/react-query';
-import { useInject } from '@/lib/di/hooks/useInject';
-import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
-import { ApiError } from '@/lib/api/base/ApiError';
-
-export function useCompleteRace(
- options?: Omit, 'mutationFn'>
-) {
- const raceService = useInject(RACE_SERVICE_TOKEN);
-
- return useMutation({
- mutationFn: (raceId) => raceService.completeRace(raceId),
- ...options,
- });
-}
diff --git a/apps/website/hooks/race/useRaceDetail.ts b/apps/website/hooks/race/useRaceDetail.ts
deleted file mode 100644
index d6ebb71d7..000000000
--- a/apps/website/hooks/race/useRaceDetail.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { useQuery } from '@tanstack/react-query';
-import { useInject } from '@/lib/di/hooks/useInject';
-import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
-import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
-
-export function useRaceDetail(raceId: string, driverId: string) {
- const raceService = useInject(RACE_SERVICE_TOKEN);
-
- const queryResult = useQuery({
- queryKey: ['raceDetail', raceId, driverId],
- queryFn: () => raceService.getRaceDetail(raceId, driverId),
- enabled: !!raceId && !!driverId,
- });
-
- return enhanceQueryResult(queryResult);
-}
diff --git a/apps/website/hooks/race/useRaceResultsDetail.ts b/apps/website/hooks/race/useRaceResultsDetail.ts
deleted file mode 100644
index 1ddcb8197..000000000
--- a/apps/website/hooks/race/useRaceResultsDetail.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { useQuery } from '@tanstack/react-query';
-import { useInject } from '@/lib/di/hooks/useInject';
-import { RACE_RESULTS_SERVICE_TOKEN } from '@/lib/di/tokens';
-import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
-
-export function useRaceResultsDetail(raceId: string, currentUserId?: string) {
- const raceResultsService = useInject(RACE_RESULTS_SERVICE_TOKEN);
-
- const queryResult = useQuery({
- queryKey: ['raceResultsDetail', raceId, currentUserId],
- queryFn: () => raceResultsService.getResultsDetail(raceId, currentUserId),
- enabled: !!raceId,
- });
-
- return enhanceQueryResult(queryResult);
-}
diff --git a/apps/website/hooks/race/useRaceResultsPageData.ts b/apps/website/hooks/race/useRaceResultsPageData.ts
new file mode 100644
index 000000000..014a234a3
--- /dev/null
+++ b/apps/website/hooks/race/useRaceResultsPageData.ts
@@ -0,0 +1,20 @@
+import { usePageDataMultiple } from '@/lib/page/usePageData';
+import { useInject } from '@/lib/di/hooks/useInject';
+import { RACE_RESULTS_SERVICE_TOKEN } from '@/lib/di/tokens';
+
+export function useRaceResultsPageData(raceId: string, currentDriverId: string) {
+ const raceResultsService = useInject(RACE_RESULTS_SERVICE_TOKEN);
+
+ return usePageDataMultiple({
+ results: {
+ queryKey: ['raceResultsDetail', raceId, currentDriverId],
+ queryFn: () => raceResultsService.getResultsDetail(raceId, currentDriverId),
+ enabled: !!raceId,
+ },
+ sof: {
+ queryKey: ['raceWithSOF', raceId],
+ queryFn: () => raceResultsService.getWithSOF(raceId),
+ enabled: !!raceId,
+ },
+ });
+}
\ No newline at end of file
diff --git a/apps/website/hooks/race/useRaceStewardingData.ts b/apps/website/hooks/race/useRaceStewardingData.ts
deleted file mode 100644
index b3a1cd260..000000000
--- a/apps/website/hooks/race/useRaceStewardingData.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { useQuery } from '@tanstack/react-query';
-import { useInject } from '@/lib/di/hooks/useInject';
-import { RACE_STEWARDING_SERVICE_TOKEN } from '@/lib/di/tokens';
-import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
-
-export function useRaceStewardingData(raceId: string, currentDriverId?: string) {
- const raceStewardingService = useInject(RACE_STEWARDING_SERVICE_TOKEN);
-
- const queryResult = useQuery({
- queryKey: ['raceStewardingData', raceId, currentDriverId],
- queryFn: () => raceStewardingService.getRaceStewardingData(raceId, currentDriverId || ''),
- enabled: !!raceId,
- });
-
- return enhanceQueryResult(queryResult);
-}
\ No newline at end of file
diff --git a/apps/website/hooks/race/useRaceWithSOF.ts b/apps/website/hooks/race/useRaceWithSOF.ts
deleted file mode 100644
index e1531ce51..000000000
--- a/apps/website/hooks/race/useRaceWithSOF.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { useQuery } from '@tanstack/react-query';
-import { useInject } from '@/lib/di/hooks/useInject';
-import { RACE_RESULTS_SERVICE_TOKEN } from '@/lib/di/tokens';
-import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
-
-export function useRaceWithSOF(raceId: string) {
- const raceResultsService = useInject(RACE_RESULTS_SERVICE_TOKEN);
-
- const queryResult = useQuery({
- queryKey: ['raceWithSOF', raceId],
- queryFn: () => raceResultsService.getWithSOF(raceId),
- enabled: !!raceId,
- });
-
- return enhanceQueryResult(queryResult);
-}
\ No newline at end of file
diff --git a/apps/website/hooks/race/useRacesPageData.ts b/apps/website/hooks/race/useRacesPageData.ts
deleted file mode 100644
index 19188be95..000000000
--- a/apps/website/hooks/race/useRacesPageData.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { useQuery } from '@tanstack/react-query';
-import { useInject } from '@/lib/di/hooks/useInject';
-import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
-import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
-
-export function useRacesPageData() {
- const raceService = useInject(RACE_SERVICE_TOKEN);
-
- const queryResult = useQuery({
- queryKey: ['racesPageData'],
- queryFn: () => raceService.getRacesPageData(),
- });
-
- return enhanceQueryResult(queryResult);
-}
diff --git a/apps/website/hooks/race/useReopenRace.ts b/apps/website/hooks/race/useReopenRace.ts
deleted file mode 100644
index 726aa5cea..000000000
--- a/apps/website/hooks/race/useReopenRace.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { useMutation, UseMutationOptions } from '@tanstack/react-query';
-import { useInject } from '@/lib/di/hooks/useInject';
-import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
-import { ApiError } from '@/lib/api/base/ApiError';
-
-export function useReopenRace(
- options?: Omit, 'mutationFn'>
-) {
- const raceService = useInject(RACE_SERVICE_TOKEN);
-
- return useMutation({
- mutationFn: (raceId) => raceService.reopenRace(raceId),
- ...options,
- });
-}
diff --git a/apps/website/hooks/sponsor/useSponsorshipRequestsPageData.ts b/apps/website/hooks/sponsor/useSponsorshipRequestsPageData.ts
new file mode 100644
index 000000000..1965bb76f
--- /dev/null
+++ b/apps/website/hooks/sponsor/useSponsorshipRequestsPageData.ts
@@ -0,0 +1,120 @@
+import { usePageData, usePageMutation } from '@/lib/page/usePageData';
+import { useInject } from '@/lib/di/hooks/useInject';
+import {
+ SPONSORSHIP_SERVICE_TOKEN,
+ DRIVER_SERVICE_TOKEN,
+ LEAGUE_SERVICE_TOKEN,
+ TEAM_SERVICE_TOKEN,
+ LEAGUE_MEMBERSHIP_SERVICE_TOKEN
+} from '@/lib/di/tokens';
+import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
+
+export function useSponsorshipRequestsPageData(currentDriverId: string | null | undefined) {
+ const sponsorshipService = useInject(SPONSORSHIP_SERVICE_TOKEN);
+ const driverService = useInject(DRIVER_SERVICE_TOKEN);
+ const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
+ const teamService = useInject(TEAM_SERVICE_TOKEN);
+ const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
+
+ const queryResult = usePageData({
+ queryKey: ['sponsorshipRequests', 'all', currentDriverId || ''],
+ queryFn: async () => {
+ if (!currentDriverId) return [];
+
+ const allSections: any[] = [];
+
+ // 1. Driver's own sponsorship requests
+ const driverRequests = await sponsorshipService.getPendingSponsorshipRequests({
+ entityType: 'driver',
+ entityId: currentDriverId,
+ });
+
+ if (driverRequests.length > 0) {
+ const driverProfile = await driverService.getDriverProfile(currentDriverId);
+ allSections.push({
+ entityType: 'driver',
+ entityId: currentDriverId,
+ entityName: driverProfile?.currentDriver?.name ?? 'Your Profile',
+ requests: driverRequests,
+ });
+ }
+
+ // 2. Leagues where the user is admin/owner
+ const allLeagues = await leagueService.getAllLeagues();
+ for (const league of allLeagues) {
+ const membership = await leagueMembershipService.getMembership(league.id, currentDriverId);
+ if (membership && LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role)) {
+ try {
+ const leagueRequests = await sponsorshipService.getPendingSponsorshipRequests({
+ entityType: 'season',
+ entityId: league.id,
+ });
+
+ if (leagueRequests.length > 0) {
+ allSections.push({
+ entityType: 'season',
+ entityId: league.id,
+ entityName: league.name,
+ requests: leagueRequests,
+ });
+ }
+ } catch (err) {
+ // Silently skip if no requests found
+ }
+ }
+ }
+
+ // 3. Teams where the user is owner/manager
+ const allTeams = await teamService.getAllTeams();
+ for (const team of allTeams) {
+ const membership = await teamService.getMembership(team.id, currentDriverId);
+ if (membership && (membership.role === 'owner' || membership.role === 'manager')) {
+ const teamRequests = await sponsorshipService.getPendingSponsorshipRequests({
+ entityType: 'team',
+ entityId: team.id,
+ });
+
+ if (teamRequests.length > 0) {
+ allSections.push({
+ entityType: 'team',
+ entityId: team.id,
+ entityName: team.name,
+ requests: teamRequests,
+ });
+ }
+ }
+ }
+
+ return allSections;
+ },
+ enabled: !!currentDriverId,
+ });
+
+ return queryResult;
+}
+
+export function useSponsorshipRequestMutations(currentDriverId: string | null | undefined, refetch: () => void) {
+ const sponsorshipService = useInject(SPONSORSHIP_SERVICE_TOKEN);
+
+ const acceptMutation = usePageMutation(
+ async ({ requestId }: { requestId: string }) => {
+ if (!currentDriverId) throw new Error('No driver ID');
+ await sponsorshipService.acceptSponsorshipRequest(requestId, currentDriverId);
+ },
+ {
+ onSuccess: () => refetch(),
+ }
+ );
+
+ const rejectMutation = usePageMutation(
+ async ({ requestId, reason }: { requestId: string; reason?: string }) => {
+ if (!currentDriverId) throw new Error('No driver ID');
+ await sponsorshipService.rejectSponsorshipRequest(requestId, currentDriverId, reason);
+ },
+ {
+ onSuccess: () => refetch(),
+ }
+ );
+
+ return { acceptMutation, rejectMutation };
+}
\ No newline at end of file
diff --git a/apps/website/hooks/useLeagueMembershipService.ts b/apps/website/hooks/useLeagueMembershipService.ts
deleted file mode 100644
index 1f4b9738f..000000000
--- a/apps/website/hooks/useLeagueMembershipService.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { useQuery } from '@tanstack/react-query';
-import { useInject } from '@/lib/di/hooks/useInject';
-import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
-import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
-
-export function useLeagueMembership(leagueId: string, driverId: string) {
- const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
-
- const queryResult = useQuery({
- queryKey: ['leagueMembership', leagueId, driverId],
- queryFn: () => leagueMembershipService.getMembership(leagueId, driverId),
- enabled: !!leagueId && !!driverId,
- });
-
- return enhanceQueryResult(queryResult);
-}
\ No newline at end of file
diff --git a/apps/website/hooks/useLeagueScoringPresets.ts b/apps/website/hooks/useLeagueScoringPresets.ts
index d7084e8d7..faa9af26c 100644
--- a/apps/website/hooks/useLeagueScoringPresets.ts
+++ b/apps/website/hooks/useLeagueScoringPresets.ts
@@ -1,15 +1,19 @@
import { useQuery } from '@tanstack/react-query';
-import { useServices } from '@/lib/services/ServiceProvider';
+import { useInject } from '@/lib/di/hooks/useInject';
+import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
+import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import type { LeagueScoringPresetDTO } from '@/lib/types/generated/LeagueScoringPresetDTO';
export function useLeagueScoringPresets() {
- const { leagueService } = useServices();
+ const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
- return useQuery({
+ const queryResult = useQuery({
queryKey: ['leagueScoringPresets'],
queryFn: async () => {
const result = await leagueService.getScoringPresets();
return result as LeagueScoringPresetDTO[];
},
});
-}
\ No newline at end of file
+
+ return enhanceQueryResult(queryResult);
+}
diff --git a/apps/website/hooks/useLeagueWizardService.ts b/apps/website/hooks/useLeagueWizardService.ts
index 7feacc8a0..ea9af6cf4 100644
--- a/apps/website/hooks/useLeagueWizardService.ts
+++ b/apps/website/hooks/useLeagueWizardService.ts
@@ -1,5 +1,6 @@
import { useMutation } from '@tanstack/react-query';
-import { useServices } from '@/lib/services/ServiceProvider';
+import { useInject } from '@/lib/di/hooks/useInject';
+import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { CreateLeagueInputDTO } from '@/lib/types/generated/CreateLeagueInputDTO';
import { CreateLeagueOutputDTO } from '@/lib/types/generated/CreateLeagueOutputDTO';
@@ -61,7 +62,7 @@ export interface LeagueWizardFormModel {
}
export function useCreateLeagueWizard() {
- const { leagueService } = useServices();
+ const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
return useMutation({
mutationFn: async (params: { form: LeagueWizardFormModel; ownerId: string }): Promise => {
@@ -79,4 +80,4 @@ export function useCreateLeagueWizard() {
return result;
},
});
-}
\ No newline at end of file
+}
diff --git a/apps/website/hooks/penalty/useRacePenalties.ts b/apps/website/hooks/usePenaltyTypesReference.ts
similarity index 69%
rename from apps/website/hooks/penalty/useRacePenalties.ts
rename to apps/website/hooks/usePenaltyTypesReference.ts
index 62729d3cc..de9219149 100644
--- a/apps/website/hooks/penalty/useRacePenalties.ts
+++ b/apps/website/hooks/usePenaltyTypesReference.ts
@@ -3,13 +3,12 @@ import { useInject } from '@/lib/di/hooks/useInject';
import { PENALTY_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
-export function useRacePenalties(raceId: string) {
+export function usePenaltyTypesReference() {
const penaltyService = useInject(PENALTY_SERVICE_TOKEN);
const queryResult = useQuery({
- queryKey: ['racePenalties', raceId],
- queryFn: () => penaltyService.findByRaceId(raceId),
- enabled: !!raceId,
+ queryKey: ['penaltyTypesReference'],
+ queryFn: () => penaltyService.getPenaltyTypesReference(),
});
return enhanceQueryResult(queryResult);
diff --git a/apps/website/lib/di/MIGRATION_SUMMARY.md b/apps/website/lib/di/MIGRATION_SUMMARY.md
deleted file mode 100644
index 086fb61f5..000000000
--- a/apps/website/lib/di/MIGRATION_SUMMARY.md
+++ /dev/null
@@ -1,280 +0,0 @@
-# Dependency Injection Migration Summary
-
-## ✅ Completed Work
-
-### 1. Core Infrastructure (100% Complete)
-- **InversifyJS** installed and configured with reflect-metadata
-- **ContainerProvider** integrated into root layout
-- **Token registry** using Symbol.for for cross-module consistency
-- **useInject()** hook for type-safe dependency injection
-- **Module system** following NestJS patterns
-
-### 2. Module Architecture (100% Complete)
-All domain modules created with proper bindings:
-
-```typescript
-// API Module
-- AnalyticsApi
-- AuthApi
-- DashboardApi
-- DriverApi
-- LeagueApi
-- MediaApi
-- PolicyApi
-- RaceApi
-- SponsorApi
-- TeamApi
-
-// Core Module
-- Logger
-- ErrorReporter
-- Config
-
-// Domain Modules
-- AnalyticsModule
-- DashboardModule
-- DriverModule
-- LandingModule
-- LeagueModule
-- PolicyModule
-- RaceModule
-- SponsorModule
-- TeamModule
-```
-
-### 3. React-Query Integration (100% Complete)
-Created 20+ hooks following SCREAMING_SNAKE_CASE pattern:
-
-**Dashboard:**
-- `useDashboardOverview()`
-
-**Driver:**
-- `useCurrentDriver()`
-- `useDriverLeaderboard()`
-
-**League:**
-- `useAllLeagues()`
-- `useLeagueAdminStatus()`
-- `useLeagueDetail()`
-- `useLeagueDetailWithSponsors()`
-- `useLeagueMemberships()`
-- `useLeagueRosterAdmin()`
-- `useLeagueSchedule()`
-- `useLeagueSettings()`
-- `useLeagueStewardingData()`
-- `useLeagueWallet()`
-- `useProtestDetail()`
-
-**Penalty:**
-- `useRacePenalties()`
-
-**Protest:**
-- `useLeagueProtests()`
-
-**Race:**
-- `useCancelRace()`
-- `useCompleteRace()`
-- `useRaceDetail()`
-- `useRaceResultsDetail()`
-- `useRacesPageData()`
-- `useRaceStewardingData()`
-- `useRaceWithSOF()`
-- `useRegisterForRace()`
-- `useReopenRace()`
-- `useWithdrawFromRace()`
-
-**Sponsor:**
-- `useAvailableLeagues()`
-
-**Team:**
-- `useAllTeams()`
-- `useTeamDetails()`
-- `useTeamMembers()`
-
-**Shared:**
-- `useCapability()`
-- `useEffectiveDriverId()`
-
-### 4. Pages Migrated to DI + React-Query (100% Complete)
-- ✅ `apps/website/app/dashboard/page.tsx` - Uses `useDashboardOverview()`
-- ✅ `apps/website/app/profile/page.tsx` - Uses `useDriverProfile()`
-- ✅ `apps/website/app/sponsor/leagues/page.tsx` - Uses `useAvailableLeagues()`
-
-### 5. Components Migrated from useServices() to useInject() (16+ files)
-- ✅ `CapabilityGate.tsx` - Uses `useCapability()`
-- ✅ `StateContainer.tsx` - Uses `useInject()` for Logger
-- ✅ `ErrorDisplay.tsx` - Uses `useInject()` for Logger
-- ✅ `LoadingWrapper.tsx` - Uses `useInject()` for Logger
-- ✅ `LoadingState.tsx` - Uses `useInject()` for Logger
-- ✅ `DriversInteractive.tsx` - Uses `useDriverLeaderboard()`
-- ✅ `LeagueRosterAdmin.tsx` - Uses `useLeagueRosterAdmin()` + mutations
-- ✅ `LeagueSettings.tsx` - Uses `useLeagueSettings()` + mutation
-- ✅ `LeagueSchedule.tsx` - Uses `useLeagueSchedule()` + mutations
-- ✅ `RaceDetail.tsx` - Uses `useRaceDetail()` + mutations
-- ✅ `RaceResultsDetail.tsx` - Uses `useRaceResultsDetail()`
-- ✅ `RaceStewarding.tsx` - Uses `useRaceStewardingData()` + mutations
-- ✅ `TeamDetails.tsx` - Uses `useTeamDetails()` + mutation
-- ✅ `TeamMembers.tsx` - Uses `useTeamMembers()` + mutation
-- ✅ `TeamRoster.tsx` - Uses `useTeamMembers()`
-- ✅ `TeamStandings.tsx` - Uses `useInject()` for leagueService
-
-### 6. DRY Error Handling (100% Complete)
-Created `enhanceQueryResult()` utility that:
-- Converts React-Query errors to `ApiError` for StateContainer compatibility
-- Provides `retry()` function for refetching
-- Eliminates repetitive error handling code
-
-### 7. Testing Infrastructure (100% Complete)
-- `createTestContainer()` utility for unit tests
-- Mock service providers
-- Test module configurations
-
-### 8. Documentation (100% Complete)
-- `README.md` - Comprehensive DI guide
-- `MIGRATION_SUMMARY.md` - This file
-
-## 🔄 Current State
-
-### Files Still Using useServices() (22 files)
-
-#### Sponsor Pages (3 files)
-1. `apps/website/app/sponsor/billing/page.tsx` - Line 263
-2. `apps/website/app/sponsor/campaigns/page.tsx` - Line 367
-3. `apps/website/app/sponsor/leagues/[id]/page.tsx` - Line 42
-
-#### Race Components (2 files)
-4. `apps/website/components/races/FileProtestModal.tsx` - Line 42
-5. `apps/website/app/races/RacesStatic.tsx` - Line 7
-
-#### Team Components (5 files)
-6. `apps/website/components/teams/TeamStandings.tsx` - Line 13
-7. `apps/website/components/teams/TeamAdmin.tsx` - Line 19
-8. `apps/website/components/teams/CreateTeamForm.tsx` - Line 17
-9. `apps/website/components/teams/TeamRoster.tsx` - Line 28
-10. `apps/website/components/teams/JoinTeamButton.tsx` - Line 32
-
-#### League Components (6 files)
-11. `apps/website/components/leagues/QuickPenaltyModal.tsx` - Line 47
-12. `apps/website/components/leagues/ScheduleRaceForm.tsx` - Line 38
-13. `apps/website/components/leagues/CreateLeagueForm.tsx` - Line 54
-14. `apps/website/components/leagues/LeagueSponsorshipsSection.tsx` - Line 32
-15. `apps/website/components/leagues/LeagueActivityFeed.tsx` - Line 35
-16. `apps/website/components/leagues/JoinLeagueButton.tsx` - Line 22
-
-#### Driver Components (3 files)
-17. `apps/website/components/drivers/DriverProfile.tsx` - Line 28
-18. `apps/website/components/drivers/CreateDriverForm.tsx` - Line 19
-19. `apps/website/components/profile/UserPill.tsx` - Line 139
-
-#### Sponsor Components (1 file)
-20. `apps/website/components/sponsors/SponsorInsightsCard.tsx` - Line 159
-
-#### Auth & Onboarding (2 files)
-21. `apps/website/lib/auth/AuthContext.tsx` - Line 34
-22. `apps/website/components/onboarding/OnboardingWizard.tsx` - Line 166
-
-## 📋 Migration Pattern
-
-### Before (Old Pattern)
-```typescript
-import { useServices } from '@/lib/services/ServiceProvider';
-
-function MyComponent() {
- const { someService } = useServices();
- const [data, setData] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- someService.getData()
- .then(setData)
- .catch(setError)
- .finally(() => setLoading(false));
- }, [someService]);
-
- if (loading) return ;
- if (error) return ;
- return {data}
;
-}
-```
-
-### After (New Pattern)
-```typescript
-// 1. Create hook in hooks/domain/
-'use client';
-import { useQuery } from '@tanstack/react-query';
-import { useInject } from '@/lib/di/hooks/useInject';
-import { SOME_SERVICE_TOKEN } from '@/lib/di/tokens';
-import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
-
-export function useSomeData() {
- const someService = useInject(SOME_SERVICE_TOKEN);
-
- const queryResult = useQuery({
- queryKey: ['some-data'],
- queryFn: () => someService.getData(),
- staleTime: 1000 * 60 * 5,
- });
-
- return enhanceQueryResult(queryResult);
-}
-
-// 2. Use in component
-import { useSomeData } from '@/hooks/domain/useSomeData';
-
-function MyComponent() {
- const { data, isLoading, isError, error } = useSomeData();
-
- if (isLoading) return ;
- if (isError) return ;
- return {data}
;
-}
-```
-
-## 🎯 Next Steps
-
-### Option 1: Continue Migration (Recommended)
-Migrate the remaining 22 files systematically:
-
-1. **Create hooks for each service usage** in `apps/website/hooks/` subdirectories
-2. **Update components** to use new hooks
-3. **Test each migration** thoroughly
-
-### Option 2: Stop Here
-The core infrastructure is complete and working. The remaining files can be migrated gradually as needed.
-
-## 🏆 Key Benefits Achieved
-
-1. **Clean Architecture**: Follows NestJS patterns, familiar to backend team
-2. **Type Safety**: Full TypeScript support with proper inference
-3. **Testability**: Easy to mock dependencies in tests
-4. **Maintainability**: Centralized dependency management
-5. **DRY Principle**: Reusable hooks with consistent error handling
-6. **Performance**: React-Query caching + DI container optimization
-
-## 📚 Key Files Reference
-
-### Infrastructure
-- `apps/website/lib/di/container.ts` - Main container
-- `apps/website/lib/di/tokens.ts` - Token registry
-- `apps/website/lib/di/hooks/useInject.ts` - Injection hook
-- `apps/website/lib/di/providers/ContainerProvider.tsx` - React provider
-
-### Modules
-- `apps/website/lib/di/modules/*.module.ts` - Domain modules
-
-### Hooks
-- `apps/website/hooks/*/*.ts` - 20+ React-Query hooks
-
-### Pages
-- `apps/website/app/dashboard/page.tsx` - Migrated
-- `apps/website/app/profile/page.tsx` - Migrated
-- `apps/website/app/sponsor/leagues/page.tsx` - Migrated
-
-### Documentation
-- `apps/website/lib/di/README.md` - Usage guide
-- `apps/website/lib/di/MIGRATION_SUMMARY.md` - This summary
-
----
-
-**Status**: ✅ Core infrastructure complete and production-ready. Remaining migration is optional and can be done incrementally.
\ No newline at end of file
diff --git a/apps/website/lib/di/README.md b/apps/website/lib/di/README.md
deleted file mode 100644
index 72aa64c89..000000000
--- a/apps/website/lib/di/README.md
+++ /dev/null
@@ -1,177 +0,0 @@
-# Dependency Injection System
-
-This directory contains the new dependency injection system for the GridPilot website, built with InversifyJS.
-
-## Overview
-
-The DI system provides:
-- **Centralized dependency management** - All services are registered in modules
-- **Type-safe injection** - Compile-time validation of dependencies
-- **Easy testing** - Simple mocking via container overrides
-- **React integration** - Hooks for component-level injection
-- **NestJS-like patterns** - Familiar structure for API developers
-
-## Architecture
-
-```
-lib/di/
-├── index.ts # Main exports
-├── container.ts # Container factory & lifecycle
-├── tokens.ts # Symbol-based tokens
-├── providers/ # React Context integration
-│ └── ContainerProvider.tsx
-├── hooks/ # Injection hooks
-│ └── useInject.ts
-└── modules/ # Domain modules
- ├── core.module.ts # Logger, error reporter, config
- ├── api.module.ts # API clients
- ├── league.module.ts # League services
- ├── driver.module.ts # Driver services
- └── team.module.ts # Team services
-```
-
-## Usage
-
-### 1. Setup Root Provider
-
-```tsx
-// app/layout.tsx
-import { ContainerProvider } from '@/lib/di/providers/ContainerProvider';
-
-export default function RootLayout({ children }) {
- return (
-
-
-
- {children}
-
-
-
- );
-}
-```
-
-### 2. Inject Services in Hooks
-
-```typescript
-// hooks/useLeagueService.ts
-import { useInject } from '@/lib/di/hooks/useInject';
-import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
-import { useQuery } from '@tanstack/react-query';
-
-export function useAllLeagues() {
- const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
-
- return useQuery({
- queryKey: ['allLeagues'],
- queryFn: () => leagueService.getAllLeagues(),
- });
-}
-```
-
-### 3. Inject in Components
-
-```typescript
-// components/MyComponent.tsx
-'use client';
-
-import { useInject } from '@/lib/di/hooks/useInject';
-import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
-
-export function MyComponent() {
- const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
-
- // Use leagueService...
-}
-```
-
-### 4. Server Components
-
-```typescript
-// app/leagues/[id]/page.tsx
-import { createContainer } from '@/lib/di/container';
-import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
-
-export default async function LeaguePage({ params }) {
- const container = createContainer();
- const leagueService = container.get(LEAGUE_SERVICE_TOKEN);
-
- const league = await leagueService.getLeague(params.id);
- return ;
-}
-```
-
-### 5. Testing
-
-```typescript
-import { createTestContainer } from '@/lib/di/container';
-import { ContainerProvider } from '@/lib/di/providers/ContainerProvider';
-import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
-
-test('component works', () => {
- const mockService = { getData: jest.fn() };
- const overrides = new Map([
- [LEAGUE_SERVICE_TOKEN, mockService]
- ]);
-
- const container = createTestContainer(overrides);
-
- render(
-
-
-
- );
-});
-```
-
-## Token Naming Convention
-
-```typescript
-// Format: DOMAIN_SERVICE_TYPE_TOKEN
-export const LEAGUE_SERVICE_TOKEN = Symbol.for('Service.League');
-export const LEAGUE_API_CLIENT_TOKEN = Symbol.for('Api.LeagueClient');
-export const LOGGER_TOKEN = Symbol.for('Core.Logger');
-```
-
-## Module Pattern
-
-```typescript
-import { ContainerModule } from 'inversify';
-import { Service } from './Service';
-import { SERVICE_TOKEN } from '../tokens';
-
-export const DomainModule = new ContainerModule((options) => {
- const bind = options.bind;
-
- bind(SERVICE_TOKEN)
- .to(Service)
- .inSingletonScope();
-});
-```
-
-## Migration from Old System
-
-### Before
-```typescript
-const { leagueService } = useServices();
-```
-
-### After
-```typescript
-const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
-```
-
-## Benefits
-
-✅ **Testability** - Easy mocking via container overrides
-✅ **Maintainability** - Clear dependency graphs
-✅ **Type Safety** - Compile-time validation
-✅ **Consistency** - Same patterns as NestJS API
-✅ **Performance** - Singleton scope by default
-
-## Next Steps
-
-1. Complete module implementations for all domains
-2. Migrate all React-Query hooks to use `useInject()`
-3. Update tests to use test containers
-4. Remove old `ServiceProvider` and `ServiceFactory`
\ No newline at end of file
diff --git a/apps/website/lib/hooks/useLeagueStewarding.ts b/apps/website/lib/hooks/useLeagueStewarding.ts
deleted file mode 100644
index 27a4f7308..000000000
--- a/apps/website/lib/hooks/useLeagueStewarding.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-'use client';
-
-import { useQuery } from '@tanstack/react-query';
-import { useInject } from '@/lib/di/hooks/useInject';
-import { LEAGUE_STEWARDING_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
-import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
-import { useCurrentDriver } from '@/hooks/driver/useCurrentDriver';
-
-/**
- * Hook for league stewarding data with admin check
- */
-export function useLeagueStewarding(leagueId: string) {
- const leagueStewardingService = useInject(LEAGUE_STEWARDING_SERVICE_TOKEN);
- const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
- const { data: currentDriver } = useCurrentDriver();
- const currentDriverId = currentDriver?.id;
-
- // Check admin status
- const adminQuery = useQuery({
- queryKey: ['leagueMembership', leagueId, currentDriverId],
- queryFn: async () => {
- if (!currentDriverId) return false;
- const membership = await leagueMembershipService.getMembership(leagueId, currentDriverId);
- return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
- },
- staleTime: 5 * 60 * 1000, // 5 minutes
- gcTime: 10 * 60 * 1000, // 10 minutes
- enabled: !!leagueId && !!currentDriverId,
- });
-
- // Load stewarding data (only if admin)
- const stewardingQuery = useQuery({
- queryKey: ['leagueStewarding', leagueId],
- queryFn: async () => {
- return await leagueStewardingService.getLeagueStewardingData(leagueId);
- },
- staleTime: 2 * 60 * 1000, // 2 minutes
- gcTime: 5 * 60 * 1000, // 5 minutes
- enabled: !!leagueId && adminQuery.data === true,
- });
-
- return {
- isAdmin: adminQuery.data,
- adminLoading: adminQuery.isLoading,
- adminError: adminQuery.error,
-
- stewardingData: stewardingQuery.data,
- stewardingLoading: stewardingQuery.isLoading,
- stewardingError: stewardingQuery.error,
-
- refetchStewarding: stewardingQuery.refetch,
- refetchAdmin: adminQuery.refetch,
- };
-}
-
-/**
- * Hook for league stewarding mutations
- */
-export function useLeagueStewardingMutations(leagueId: string) {
- const leagueStewardingService = useInject(LEAGUE_STEWARDING_SERVICE_TOKEN);
- const { data: currentDriver } = useCurrentDriver();
- const currentDriverId = currentDriver?.id;
-
- const reviewProtest = async (input: { protestId: string; decision: string; decisionNotes: string }) => {
- if (!currentDriverId) throw new Error('No current driver');
- return await leagueStewardingService.reviewProtest({
- protestId: input.protestId,
- stewardId: currentDriverId,
- decision: input.decision,
- decisionNotes: input.decisionNotes,
- });
- };
-
- const applyPenalty = async (input: {
- raceId: string;
- driverId: string;
- type: string;
- value: number;
- reason: string;
- protestId: string;
- notes: string;
- }) => {
- if (!currentDriverId) throw new Error('No current driver');
- return await leagueStewardingService.applyPenalty({
- raceId: input.raceId,
- driverId: input.driverId,
- stewardId: currentDriverId,
- type: input.type,
- value: input.value,
- reason: input.reason,
- protestId: input.protestId,
- notes: input.notes,
- });
- };
-
- return {
- reviewProtest,
- applyPenalty,
- };
-}
\ No newline at end of file
diff --git a/apps/website/lib/hooks/useRaceResults.ts b/apps/website/lib/hooks/useRaceResults.ts
deleted file mode 100644
index bf2b677ac..000000000
--- a/apps/website/lib/hooks/useRaceResults.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-'use client';
-
-import { useQuery } from '@tanstack/react-query';
-import { useInject } from '@/lib/di/hooks/useInject';
-import { RACE_RESULTS_SERVICE_TOKEN } from '@/lib/di/tokens';
-
-/**
- * Hook for race results data
- */
-export function useRaceResults(raceId: string, currentUserId?: string) {
- const raceResultsService = useInject(RACE_RESULTS_SERVICE_TOKEN);
-
- const raceQuery = useQuery({
- queryKey: ['raceResults', raceId, currentUserId],
- queryFn: async () => {
- return await raceResultsService.getResultsDetail(raceId, currentUserId);
- },
- staleTime: 2 * 60 * 1000, // 2 minutes
- gcTime: 5 * 60 * 1000, // 5 minutes
- enabled: !!raceId,
- });
-
- const sofQuery = useQuery({
- queryKey: ['raceSof', raceId],
- queryFn: async () => {
- return await raceResultsService.getWithSOF(raceId);
- },
- staleTime: 2 * 60 * 1000, // 2 minutes
- gcTime: 5 * 60 * 1000, // 5 minutes
- enabled: !!raceId,
- });
-
- return {
- raceData: raceQuery.data,
- isLoading: raceQuery.isLoading || sofQuery.isLoading,
- error: raceQuery.error || sofQuery.error,
- retry: raceQuery.refetch,
- sofData: sofQuery.data,
- };
-}
\ No newline at end of file
diff --git a/apps/website/lib/hooks/useRaceStewarding.ts b/apps/website/lib/hooks/useRaceStewarding.ts
deleted file mode 100644
index 7d844a941..000000000
--- a/apps/website/lib/hooks/useRaceStewarding.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-'use client';
-
-import { useQuery } from '@tanstack/react-query';
-import { useInject } from '@/lib/di/hooks/useInject';
-import { RACE_STEWARDING_SERVICE_TOKEN } from '@/lib/di/tokens';
-
-/**
- * Hook for race stewarding data
- */
-export function useRaceStewarding(raceId: string, driverId: string) {
- const raceStewardingService = useInject(RACE_STEWARDING_SERVICE_TOKEN);
-
- const query = useQuery({
- queryKey: ['raceStewarding', raceId, driverId],
- queryFn: async () => {
- return await raceStewardingService.getRaceStewardingData(raceId, driverId);
- },
- staleTime: 2 * 60 * 1000, // 2 minutes
- gcTime: 5 * 60 * 1000, // 5 minutes
- enabled: !!raceId && !!driverId,
- });
-
- return {
- data: query.data,
- isLoading: query.isLoading,
- error: query.error,
- retry: query.refetch,
- };
-}
\ No newline at end of file
diff --git a/apps/website/lib/hooks/useTeamLeaderboard.ts b/apps/website/lib/hooks/useTeamLeaderboard.ts
deleted file mode 100644
index e949fe10c..000000000
--- a/apps/website/lib/hooks/useTeamLeaderboard.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-'use client';
-
-import { useQuery } from '@tanstack/react-query';
-import { useInject } from '@/lib/di/hooks/useInject';
-import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
-
-/**
- * Hook for team leaderboard data
- */
-export function useTeamLeaderboard() {
- const teamService = useInject(TEAM_SERVICE_TOKEN);
-
- const query = useQuery({
- queryKey: ['allTeams'],
- queryFn: async () => {
- return await teamService.getAllTeams();
- },
- staleTime: 5 * 60 * 1000, // 5 minutes
- gcTime: 10 * 60 * 1000, // 10 minutes
- });
-
- return {
- data: query.data,
- isLoading: query.isLoading,
- error: query.error,
- retry: query.refetch,
- };
-}
\ No newline at end of file
diff --git a/apps/website/lib/page/PageDataFetcher.ts b/apps/website/lib/page/PageDataFetcher.ts
new file mode 100644
index 000000000..6f8f11478
--- /dev/null
+++ b/apps/website/lib/page/PageDataFetcher.ts
@@ -0,0 +1,87 @@
+import { ContainerManager } from '@/lib/di/container';
+
+export interface FetchResult {
+ data: T | null;
+ errors: Record;
+ hasErrors: boolean;
+}
+
+export class PageDataFetcher {
+ /**
+ * Fetch data using DI container
+ * Use for: Simple SSR pages with single service
+ * WARNING: Container is singleton - avoid stateful services
+ */
+ static async fetch(
+ ServiceToken: string | symbol,
+ method: TMethod,
+ ...args: TService[TMethod] extends (...params: infer P) => Promise ? P : never
+ ): Promise<(TService[TMethod] extends (...params: any[]) => Promise ? R : never) | null> {
+ try {
+ const container = ContainerManager.getInstance().getContainer();
+ const service = container.get(ServiceToken);
+ const result = await (service[method] as Function)(...args);
+ return result;
+ } catch (error) {
+ console.error(`Failed to fetch: ${String(ServiceToken)}.${String(method)}`, error);
+ return null;
+ }
+ }
+
+ /**
+ * Fetch using manual service instantiation
+ * Use for: Multiple dependencies, request-scoped services, or auth context
+ * RECOMMENDED for SSR over fetch() with DI
+ */
+ static async fetchManual(
+ serviceFactory: () => Promise | TData
+ ): Promise {
+ try {
+ const result = await serviceFactory();
+ return result;
+ } catch (error) {
+ console.error('Failed to fetch manual:', error);
+ return null;
+ }
+ }
+
+ /**
+ * Fetch multiple datasets in parallel with error aggregation
+ * Use for: Pages needing multiple service calls
+ * UPDATED: Returns both data and errors for proper handling
+ */
+ static async fetchMultiple>(
+ queries: T
+ ): Promise> {
+ const results = {} as { [K in keyof T]: T[K] };
+ const errors = {} as Record;
+
+ const entries = await Promise.all(
+ Object.entries(queries).map(async ([key, query]) => {
+ try {
+ const result = await query();
+ return [key, { success: true, data: result }] as const;
+ } catch (error) {
+ console.error(`Failed to fetch ${key}:`, error);
+ return [key, { success: false, error: error instanceof Error ? error : new Error(String(error)) }] as const;
+ }
+ })
+ );
+
+ entries.forEach(([key, result]) => {
+ if (typeof result === 'object' && result !== null && 'success' in result) {
+ if (result.success) {
+ results[key as keyof T] = (result as any).data;
+ } else {
+ errors[key] = (result as any).error;
+ }
+ }
+ });
+
+ return {
+ data: results,
+ errors,
+ hasErrors: Object.keys(errors).length > 0
+ };
+ }
+}
\ No newline at end of file
diff --git a/apps/website/lib/page/usePageData.ts b/apps/website/lib/page/usePageData.ts
new file mode 100644
index 000000000..5c23ab23d
--- /dev/null
+++ b/apps/website/lib/page/usePageData.ts
@@ -0,0 +1,149 @@
+'use client';
+
+import React from 'react';
+import { useQuery, useQueries, UseQueryOptions, useMutation, UseMutationOptions } from '@tanstack/react-query';
+import { ApiError } from '@/lib/api/base/ApiError';
+
+export interface PageDataConfig {
+ queryKey: string[];
+ queryFn: () => Promise;
+ enabled?: boolean;
+ staleTime?: number;
+ onError?: (error: TError) => void;
+}
+
+/**
+ * Single query hook - STANDARDIZED PATTERN
+ * Use for: Simple CSR pages
+ *
+ * @example
+ * const { data, isLoading, error, refetch } = usePageData({
+ * queryKey: ['profile'],
+ * queryFn: () => driverService.getProfile(),
+ * });
+ */
+export function usePageData(
+ config: PageDataConfig
+) {
+ const queryOptions: any = {
+ queryKey: config.queryKey,
+ queryFn: config.queryFn,
+ enabled: config.enabled ?? true,
+ staleTime: config.staleTime ?? 1000 * 60 * 5,
+ };
+
+ if (config.onError) {
+ queryOptions.onError = config.onError;
+ }
+
+ return useQuery(queryOptions);
+}
+
+/**
+ * Multiple queries hook - STANDARDIZED PATTERN
+ * Use for: Complex CSR pages with multiple data sources
+ *
+ * @example
+ * const { data, isLoading, error, refetch } = usePageDataMultiple({
+ * results: {
+ * queryKey: ['raceResults', raceId],
+ * queryFn: () => service.getResults(raceId),
+ * },
+ * sof: {
+ * queryKey: ['raceSOF', raceId],
+ * queryFn: () => service.getSOF(raceId),
+ * },
+ * });
+ */
+export function usePageDataMultiple>(
+ queries: {
+ [K in keyof T]: PageDataConfig;
+ }
+) {
+ const queryResults = useQueries({
+ queries: Object.entries(queries).map(([key, config]) => {
+ const queryOptions: any = {
+ queryKey: config.queryKey,
+ queryFn: config.queryFn,
+ enabled: config.enabled ?? true,
+ staleTime: config.staleTime ?? 1000 * 60 * 5,
+ };
+ if (config.onError) {
+ queryOptions.onError = config.onError;
+ }
+ return queryOptions;
+ }),
+ });
+
+ // Combine results
+ const combined = {} as { [K in keyof T]: T[K] | null };
+ const keys = Object.keys(queries) as (keyof T)[];
+
+ keys.forEach((key, index) => {
+ const result = queryResults[index]?.data;
+ if (result !== undefined) {
+ combined[key] = result as T[typeof key];
+ } else {
+ combined[key] = null as T[typeof key] | null;
+ }
+ });
+
+ const isLoading = queryResults.some(q => q.isLoading);
+ const error = queryResults.find(q => q.error)?.error ?? null;
+
+ return {
+ data: combined,
+ isLoading,
+ error,
+ refetch: () => queryResults.forEach(q => q.refetch()),
+ };
+}
+
+/**
+ * Mutation hook wrapper - STANDARDIZED PATTERN
+ * Use for: All mutation operations
+ *
+ * @example
+ * const mutation = usePageMutation(
+ * (variables) => service.mutateData(variables),
+ * { onSuccess: () => refetch() }
+ * );
+ */
+export function usePageMutation(
+ mutationFn: (variables: TVariables) => Promise,
+ options?: Omit, 'mutationFn'>
+) {
+ return useMutation({
+ mutationFn,
+ ...options,
+ });
+}
+
+/**
+ * SSR Hydration Hook - NEW
+ * Use for: Passing SSR data to CSR to avoid re-fetching
+ *
+ * @example
+ * // In SSR page
+ * const ssrData = await PageDataFetcher.fetch(...);
+ *
+ * // In client component
+ * const { data } = useHydrateSSRData(ssrData, ['queryKey']);
+ */
+export function useHydrateSSRData(
+ ssrData: TData | null,
+ queryKey: string[]
+): { data: TData | null; isHydrated: boolean } {
+ const [isHydrated, setIsHydrated] = React.useState(false);
+
+ React.useEffect(() => {
+ if (ssrData !== null) {
+ setIsHydrated(true);
+ }
+ }, [ssrData]);
+
+ return {
+ data: ssrData,
+ isHydrated,
+ };
+}
\ No newline at end of file
diff --git a/apps/website/lib/services/home/getHomeData.ts b/apps/website/lib/services/home/getHomeData.ts
new file mode 100644
index 000000000..1bb936f12
--- /dev/null
+++ b/apps/website/lib/services/home/getHomeData.ts
@@ -0,0 +1,28 @@
+import { ContainerManager } from '@/lib/di/container';
+import { SESSION_SERVICE_TOKEN, LANDING_SERVICE_TOKEN } from '@/lib/di/tokens';
+import { LandingService } from '@/lib/services/landing/LandingService';
+import { SessionService } from '@/lib/services/auth/SessionService';
+import { redirect } from 'next/navigation';
+import { FeatureFlagService } from '@/lib/feature/FeatureFlagService';
+
+export async function getHomeData() {
+ const container = ContainerManager.getInstance().getContainer();
+ const sessionService = container.get(SESSION_SERVICE_TOKEN);
+ const landingService = container.get(LANDING_SERVICE_TOKEN);
+
+ const session = await sessionService.getSession();
+ if (session) {
+ redirect('/dashboard');
+ }
+
+ const featureService = FeatureFlagService.fromEnv();
+ const isAlpha = featureService.isEnabled('alpha_features');
+ const discovery = await landingService.getHomeDiscovery();
+
+ return {
+ isAlpha,
+ upcomingRaces: discovery.upcomingRaces,
+ topLeagues: discovery.topLeagues,
+ teams: discovery.teams,
+ };
+}
\ No newline at end of file
diff --git a/apps/website/lib/transformers/RaceResultsDataTransformer.ts b/apps/website/lib/transformers/RaceResultsDataTransformer.ts
new file mode 100644
index 000000000..5ad437ccf
--- /dev/null
+++ b/apps/website/lib/transformers/RaceResultsDataTransformer.ts
@@ -0,0 +1,105 @@
+import type { LeagueMembershipsViewModel } from '@/lib/view-models/LeagueMembershipsViewModel';
+import type { RaceResultsDetailViewModel } from '@/lib/view-models/RaceResultsDetailViewModel';
+import type { RaceWithSOFViewModel } from '@/lib/view-models/RaceWithSOFViewModel';
+
+// TODO fucking violating our architecture, it should be a ViewModel
+
+export interface TransformedRaceResultsData {
+ raceTrack?: string;
+ raceScheduledAt?: string;
+ totalDrivers?: number;
+ leagueName?: string;
+ raceSOF: number | null;
+ results: Array<{
+ position: number;
+ driverId: string;
+ driverName: string;
+ driverAvatar: string;
+ country: string;
+ car: string;
+ laps: number;
+ time: string;
+ fastestLap: string;
+ points: number;
+ incidents: number;
+ isCurrentUser: boolean;
+ }>;
+ penalties: Array<{
+ driverId: string;
+ driverName: string;
+ type: 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points';
+ value: number;
+ reason: string;
+ notes?: string;
+ }>;
+ pointsSystem: Record;
+ fastestLapTime: number;
+ memberships?: Array<{
+ driverId: string;
+ role: string;
+ }>;
+}
+
+export class RaceResultsDataTransformer {
+ static transform(
+ resultsData: RaceResultsDetailViewModel | null,
+ sofData: RaceWithSOFViewModel | null,
+ currentDriverId: string,
+ membershipsData?: LeagueMembershipsViewModel
+ ): TransformedRaceResultsData {
+ if (!resultsData) {
+ return {
+ raceSOF: null,
+ results: [],
+ penalties: [],
+ pointsSystem: {},
+ fastestLapTime: 0,
+ };
+ }
+
+ // Transform results
+ const results = resultsData.results.map((result: any) => ({
+ position: result.position,
+ driverId: result.driverId,
+ driverName: result.driverName,
+ driverAvatar: result.avatarUrl,
+ country: 'US', // Default since view model doesn't have car
+ car: 'Unknown', // Default since view model doesn't have car
+ laps: 0, // Default since view model doesn't have laps
+ time: '0:00.00', // Default since view model doesn't have time
+ fastestLap: result.fastestLap.toString(), // Convert number to string
+ points: 0, // Default since view model doesn't have points
+ incidents: result.incidents,
+ isCurrentUser: result.driverId === currentDriverId,
+ }));
+
+ // Transform penalties
+ const penalties = resultsData.penalties.map((penalty: any) => ({
+ driverId: penalty.driverId,
+ driverName: resultsData.results.find((r: any) => r.driverId === penalty.driverId)?.driverName || 'Unknown',
+ type: penalty.type as 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points',
+ value: penalty.value || 0,
+ reason: 'Penalty applied', // Default since view model doesn't have reason
+ notes: undefined, // Default since view model doesn't have notes
+ }));
+
+ // Transform memberships
+ const memberships = membershipsData?.memberships.map((membership: any) => ({
+ driverId: membership.driverId,
+ role: membership.role || 'member',
+ }));
+
+ return {
+ raceTrack: resultsData.race?.track,
+ raceScheduledAt: resultsData.race?.scheduledAt,
+ totalDrivers: resultsData.stats?.totalDrivers,
+ leagueName: resultsData.league?.name,
+ raceSOF: sofData?.strengthOfField || null,
+ results,
+ penalties,
+ pointsSystem: resultsData.pointsSystem || {},
+ fastestLapTime: resultsData.fastestLapTime || 0,
+ memberships,
+ };
+ }
+}
\ No newline at end of file
diff --git a/apps/website/lib/utils.ts b/apps/website/lib/utils.ts
new file mode 100644
index 000000000..bf8c8aee9
--- /dev/null
+++ b/apps/website/lib/utils.ts
@@ -0,0 +1,15 @@
+/**
+ * Utility function to check if code is running on server or client
+ * @returns true if running on server (SSR), false if running on client (browser)
+ */
+export function isServer(): boolean {
+ return typeof window === 'undefined';
+}
+
+/**
+ * Utility function to check if code is running on client
+ * @returns true if running on client (browser), false if running on server (SSR)
+ */
+export function isClient(): boolean {
+ return typeof window !== 'undefined';
+}
\ No newline at end of file
diff --git a/apps/website/templates/DashboardTemplate.tsx b/apps/website/templates/DashboardTemplate.tsx
new file mode 100644
index 000000000..872b25cae
--- /dev/null
+++ b/apps/website/templates/DashboardTemplate.tsx
@@ -0,0 +1,316 @@
+'use client';
+
+import {
+ Activity,
+ Award,
+ Calendar,
+ ChevronRight,
+ Clock,
+ Flag,
+ Medal,
+ Play,
+ Star,
+ Target,
+ Trophy,
+ UserPlus,
+ Users,
+} from 'lucide-react';
+import Image from 'next/image';
+import Link from 'next/link';
+
+import { FeedItemRow } from '@/components/dashboard/FeedItemRow';
+import { FriendItem } from '@/components/dashboard/FriendItem';
+import { LeagueStandingItem } from '@/components/dashboard/LeagueStandingItem';
+import { StatCard } from '@/components/dashboard/StatCard';
+import { UpcomingRaceItem } from '@/components/dashboard/UpcomingRaceItem';
+import Button from '@/components/ui/Button';
+import Card from '@/components/ui/Card';
+
+import { getCountryFlag } from '@/lib/utilities/country';
+import { getGreeting, timeUntil } from '@/lib/utilities/time';
+
+import { DashboardOverviewViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
+
+interface DashboardTemplateProps {
+ data: DashboardOverviewViewModel;
+}
+
+export function DashboardTemplate({ data }: DashboardTemplateProps) {
+ const currentDriver = data.currentDriver;
+ const nextRace = data.nextRace;
+ const upcomingRaces = data.upcomingRaces;
+ const leagueStandingsSummaries = data.leagueStandings;
+ const feedSummary = { items: data.feedItems };
+ const friends = data.friends;
+ const activeLeaguesCount = data.activeLeaguesCount;
+
+ const { totalRaces, wins, podiums, rating, globalRank, consistency } = currentDriver;
+
+ return (
+
+ {/* Hero Section */}
+
+ {/* Background Pattern */}
+
+
+
+
+
+ {/* Welcome Message */}
+
+
+
+
{getGreeting()},
+
+ {currentDriver.name}
+ {getCountryFlag(currentDriver.country)}
+
+
+
+
+ {rating}
+
+
+
+ #{globalRank}
+
+
{totalRaces} races completed
+
+
+
+
+ {/* Quick Actions */}
+
+
+
+
+ Browse Leagues
+
+
+
+
+
+ View Profile
+
+
+
+
+
+ {/* Quick Stats Row */}
+
+
+
+
+
+
+
+
+
+ {/* Main Content */}
+
+
+ {/* Left Column - Main Content */}
+
+ {/* Next Race Card */}
+ {nextRace && (
+
+
+
+
+
+ {nextRace.isMyLeague && (
+
+ Your League
+
+ )}
+
+
+
+
+
{nextRace.track}
+
{nextRace.car}
+
+
+
+ {nextRace.scheduledAt.toLocaleDateString('en-US', {
+ weekday: 'long',
+ month: 'short',
+ day: 'numeric',
+ })}
+
+
+
+ {nextRace.scheduledAt.toLocaleTimeString('en-US', {
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+
+
+
+
+
+
Starts in
+
{timeUntil(nextRace.scheduledAt)}
+
+
+
+ View Details
+
+
+
+
+
+
+
+ )}
+
+ {/* League Standings Preview */}
+ {leagueStandingsSummaries.length > 0 && (
+
+
+
+
+ Your Championship Standings
+
+
+ View all
+
+
+
+ {leagueStandingsSummaries.map((summary: any) => (
+
+ ))}
+
+
+ )}
+
+ {/* Activity Feed */}
+
+
+
+
+ Recent Activity
+
+
+ {feedSummary.items.length > 0 ? (
+
+ {feedSummary.items.slice(0, 5).map((item: any) => (
+
+ ))}
+
+ ) : (
+
+
+
No activity yet
+
Join leagues and add friends to see activity here
+
+ )}
+
+
+
+ {/* Right Column - Sidebar */}
+
+ {/* Upcoming Races */}
+
+
+
+
+ Upcoming Races
+
+
+ View all
+
+
+ {upcomingRaces.length > 0 ? (
+
+ {upcomingRaces.slice(0, 5).map((race: any) => (
+
+ ))}
+
+ ) : (
+ No upcoming races
+ )}
+
+
+ {/* Friends */}
+
+
+
+
+ Friends
+
+ {friends.length} friends
+
+ {friends.length > 0 ? (
+
+ {friends.slice(0, 6).map((friend: any) => (
+
+ ))}
+ {friends.length > 6 && (
+
+ +{friends.length - 6} more
+
+ )}
+
+ ) : (
+
+
+
No friends yet
+
+
+ Find Drivers
+
+
+
+ )}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/templates/DriversTemplate.tsx b/apps/website/templates/DriversTemplate.tsx
index 474bdc7f4..35c9b25e8 100644
--- a/apps/website/templates/DriversTemplate.tsx
+++ b/apps/website/templates/DriversTemplate.tsx
@@ -18,22 +18,19 @@ import { CategoryDistribution } from '@/components/drivers/CategoryDistribution'
import { LeaderboardPreview } from '@/components/drivers/LeaderboardPreview';
import { RecentActivity } from '@/components/drivers/RecentActivity';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
+import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
interface DriversTemplateProps {
- drivers: DriverLeaderboardItemViewModel[];
- totalRaces: number;
- totalWins: number;
- activeCount: number;
- isLoading?: boolean;
+ data: DriverLeaderboardViewModel | null;
}
-export function DriversTemplate({
- drivers,
- totalRaces,
- totalWins,
- activeCount,
- isLoading = false
-}: DriversTemplateProps) {
+export function DriversTemplate({ data }: DriversTemplateProps) {
+ const drivers = data?.drivers || [];
+ const totalRaces = data?.totalRaces || 0;
+ const totalWins = data?.totalWins || 0;
+ const activeCount = data?.activeCount || 0;
+ const isLoading = false;
+
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
diff --git a/apps/website/templates/HomeTemplate.tsx b/apps/website/templates/HomeTemplate.tsx
new file mode 100644
index 000000000..d3c2e5fc8
--- /dev/null
+++ b/apps/website/templates/HomeTemplate.tsx
@@ -0,0 +1,355 @@
+import Image from 'next/image';
+import Hero from '@/components/landing/Hero';
+import AlternatingSection from '@/components/landing/AlternatingSection';
+import FeatureGrid from '@/components/landing/FeatureGrid';
+import DiscordCTA from '@/components/landing/DiscordCTA';
+import FAQ from '@/components/landing/FAQ';
+import Footer from '@/components/landing/Footer';
+import CareerProgressionMockup from '@/components/mockups/CareerProgressionMockup';
+import RaceHistoryMockup from '@/components/mockups/RaceHistoryMockup';
+import CompanionAutomationMockup from '@/components/mockups/CompanionAutomationMockup';
+import SimPlatformMockup from '@/components/mockups/SimPlatformMockup';
+import MockupStack from '@/components/ui/MockupStack';
+import Card from '@/components/ui/Card';
+import Button from '@/components/ui/Button';
+import { getMediaUrl } from '@/lib/utilities/media';
+
+export interface HomeTemplateData {
+ isAlpha: boolean;
+ upcomingRaces: any[];
+ topLeagues: any[];
+ teams: any[];
+}
+
+export interface HomeTemplateProps {
+ data: HomeTemplateData;
+}
+
+export default function HomeTemplate({ data }: HomeTemplateProps) {
+ return (
+
+
+
+ {/* Section 1: A Persistent Identity */}
+
+
+ Your races, your seasons, your progress — finally in one place.
+
+
+
+
+
+
+
+ Lifetime stats and season history across all your leagues
+
+
+
+
+
+
+
+
+ Track your performance, consistency, and team contributions
+
+
+
+
+
+
+
+
+ Your own rating that reflects real league competition
+
+
+
+
+
+ iRacing gives you physics. GridPilot gives you a career.
+
+ >
+ }
+ mockup={ }
+ layout="text-left"
+ />
+
+
+
+ {/* Section 2: Results That Actually Stay */}
+
+
+ Every race you run stays with you.
+
+
+
+
+
+
+
+ Your stats, your team, your story — all connected
+
+
+
+
+
+
+
+
+ One race result updates your profile, team points, rating, and season history
+
+
+
+
+
+
+
+
+ No more fragmented data across spreadsheets and forums
+
+
+
+
+
+ Your racing career, finally in one place.
+
+ >
+ }
+ mockup={ }
+ layout="text-right"
+ />
+
+ {/* Section 3: Automatic Session Creation */}
+
+
+ Setting up league races used to mean clicking through iRacing's wizard 20 times.
+
+
+
+
+
+
+ 1
+
+
+ Our companion app syncs with your league schedule
+
+
+
+
+
+
+
+ 2
+
+
+ When it's race time, it creates the iRacing session automatically
+
+
+
+
+
+
+
+ 3
+
+
+ No clicking through wizards. No manual setup
+
+
+
+
+
+ Automation instead of repetition.
+
+ >
+ }
+ mockup={ }
+ layout="text-left"
+ />
+
+ {/* Section 4: Game-Agnostic Platform */}
+
+
+ Right now, we're focused on making iRacing league racing better.
+
+
+ But sims come and go. Your leagues, your teams, your rating — those stay.
+
+
+ GridPilot is built to outlast any single platform.
+
+
+ When the next sim arrives, your competitive identity moves with you.
+
+ >
+ }
+ mockup={ }
+ layout="text-right"
+ />
+
+ {/* Alpha-only discovery section */}
+ {data.isAlpha && (
+
+
+
+
Discover the grid
+
+ Explore leagues, teams, and races that make up the GridPilot ecosystem.
+
+
+
+
+
+ {/* Top leagues */}
+
+
+
Featured leagues
+
+ View all
+
+
+
+
+
+ {/* Teams */}
+
+
+
Teams on the grid
+
+ Browse teams
+
+
+
+ {data.teams.slice(0, 4).map(team => (
+
+
+
+
+
+
{team.name}
+
+ {team.description}
+
+
+
+ ))}
+
+
+
+ {/* Upcoming races */}
+
+
+
Upcoming races
+
+ View schedule
+
+
+ {data.upcomingRaces.length === 0 ? (
+
+ No races scheduled in this demo snapshot.
+
+ ) : (
+
+ {data.upcomingRaces.map(race => (
+
+
+
{race.track}
+
{race.car}
+
+
+ {race.formattedDate}
+
+
+ ))}
+
+ )}
+
+
+
+ )}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/templates/LeagueAdminScheduleTemplate.tsx b/apps/website/templates/LeagueAdminScheduleTemplate.tsx
new file mode 100644
index 000000000..be99fb01b
--- /dev/null
+++ b/apps/website/templates/LeagueAdminScheduleTemplate.tsx
@@ -0,0 +1,234 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import type { LeagueAdminScheduleViewModel } from '@/lib/view-models/LeagueAdminScheduleViewModel';
+import type { LeagueSeasonSummaryViewModel } from '@/lib/view-models/LeagueSeasonSummaryViewModel';
+import Card from '@/components/ui/Card';
+
+// ============================================================================
+// TYPES
+// ============================================================================
+
+interface LeagueAdminScheduleTemplateProps {
+ data: {
+ schedule: LeagueAdminScheduleViewModel;
+ seasons: LeagueSeasonSummaryViewModel[];
+ seasonId: string;
+ };
+ onSeasonChange: (seasonId: string) => void;
+ onPublishToggle: () => void;
+ onAddOrSave: () => void;
+ onEdit: (raceId: string) => void;
+ onDelete: (raceId: string) => void;
+ onCancelEdit: () => void;
+
+ // Form state
+ track: string;
+ car: string;
+ scheduledAtIso: string;
+ editingRaceId: string | null;
+
+ // Mutation states
+ isPublishing: boolean;
+ isSaving: boolean;
+ isDeleting: string | null;
+
+ // Form setters
+ setTrack: (value: string) => void;
+ setCar: (value: string) => void;
+ setScheduledAtIso: (value: string) => void;
+}
+
+// ============================================================================
+// MAIN TEMPLATE COMPONENT
+// ============================================================================
+
+export function LeagueAdminScheduleTemplate({
+ data,
+ onSeasonChange,
+ onPublishToggle,
+ onAddOrSave,
+ onEdit,
+ onDelete,
+ onCancelEdit,
+ track,
+ car,
+ scheduledAtIso,
+ editingRaceId,
+ isPublishing,
+ isSaving,
+ isDeleting,
+ setTrack,
+ setCar,
+ setScheduledAtIso,
+}: LeagueAdminScheduleTemplateProps) {
+ const { schedule, seasons, seasonId } = data;
+
+ const isEditing = editingRaceId !== null;
+ const publishedLabel = schedule.published ? 'Published' : 'Unpublished';
+
+ const selectedSeasonLabel = useMemo(() => {
+ const selected = seasons.find((s) => s.seasonId === seasonId);
+ return selected?.name ?? seasonId;
+ }, [seasons, seasonId]);
+
+ return (
+
+
+
+
+
Schedule Admin
+
Create, edit, and publish season races.
+
+
+
+
+ Season
+
+ {seasons.length > 0 ? (
+
onSeasonChange(e.target.value)}
+ className="bg-iron-gray text-white px-3 py-2 rounded"
+ >
+ {seasons.map((s) => (
+
+ {s.name}
+
+ ))}
+
+ ) : (
+
onSeasonChange(e.target.value)}
+ className="bg-iron-gray text-white px-3 py-2 rounded"
+ placeholder="season-id"
+ />
+ )}
+
Selected: {selectedSeasonLabel}
+
+
+
+
+ Status: {publishedLabel}
+
+
+ {isPublishing ? 'Processing...' : (schedule?.published ? 'Unpublish' : 'Publish')}
+
+
+
+
+
{isEditing ? 'Edit race' : 'Add race'}
+
+
+
+
+
+ {isSaving ? 'Processing...' : (isEditing ? 'Save' : 'Add race')}
+
+
+ {isEditing && (
+
+ Cancel
+
+ )}
+
+
+
+
+
Races
+
+ {schedule?.races.length ? (
+
+ {schedule.races.map((race) => (
+
+
+
{race.name}
+
{race.scheduledAt.toISOString()}
+
+
+
+ onEdit(race.id)}
+ className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
+ >
+ Edit
+
+ onDelete(race.id)}
+ disabled={isDeleting === race.id}
+ className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
+ >
+ {isDeleting === race.id ? 'Deleting...' : 'Delete'}
+
+
+
+ ))}
+
+ ) : (
+
No races yet.
+ )}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/templates/LeagueScheduleTemplate.tsx b/apps/website/templates/LeagueScheduleTemplate.tsx
index cac9dfb10..a34e3a72b 100644
--- a/apps/website/templates/LeagueScheduleTemplate.tsx
+++ b/apps/website/templates/LeagueScheduleTemplate.tsx
@@ -1,6 +1,14 @@
'use client';
-import LeagueSchedule from '@/components/leagues/LeagueSchedule';
+import { useMemo, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import type { LeagueScheduleViewModel, LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
+import { StateContainer } from '@/components/shared/state/StateContainer';
+import { EmptyState } from '@/components/shared/state/EmptyState';
+import { Calendar } from 'lucide-react';
+import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
+import { useRegisterForRace } from '@/hooks/race/useRegisterForRace';
+import { useWithdrawFromRace } from '@/hooks/race/useWithdrawFromRace';
import Card from '@/components/ui/Card';
// ============================================================================
@@ -8,8 +16,8 @@ import Card from '@/components/ui/Card';
// ============================================================================
interface LeagueScheduleTemplateProps {
+ data: LeagueScheduleViewModel;
leagueId: string;
- loading?: boolean;
}
// ============================================================================
@@ -17,22 +25,224 @@ interface LeagueScheduleTemplateProps {
// ============================================================================
export function LeagueScheduleTemplate({
+ data,
leagueId,
- loading = false,
}: LeagueScheduleTemplateProps) {
- if (loading) {
- return (
-
- Loading schedule...
-
- );
- }
+ const router = useRouter();
+ const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming');
+ const currentDriverId = useEffectiveDriverId();
+ const registerMutation = useRegisterForRace();
+ const withdrawMutation = useWithdrawFromRace();
+
+ const races = useMemo(() => {
+ return data?.races ?? [];
+ }, [data]);
+
+ const upcomingRaces = races.filter((race) => race.isUpcoming);
+ const pastRaces = races.filter((race) => race.isPast);
+
+ const getDisplayRaces = () => {
+ switch (filter) {
+ case 'upcoming':
+ return upcomingRaces;
+ case 'past':
+ return [...pastRaces].reverse();
+ case 'all':
+ return [...upcomingRaces, ...[...pastRaces].reverse()];
+ default:
+ return races;
+ }
+ };
+
+ const displayRaces = getDisplayRaces();
+
+ const handleRegister = async (race: LeagueScheduleRaceViewModel, e: React.MouseEvent) => {
+ e.stopPropagation();
+
+ const confirmed = window.confirm(`Register for ${race.track ?? race.name}?`);
+
+ if (!confirmed) return;
+
+ if (!currentDriverId) {
+ alert('You must be logged in to register for races');
+ return;
+ }
+
+ try {
+ await registerMutation.mutateAsync({ raceId: race.id, leagueId, driverId: currentDriverId });
+ } catch (err) {
+ alert(err instanceof Error ? err.message : 'Failed to register');
+ }
+ };
+
+ const handleWithdraw = async (race: LeagueScheduleRaceViewModel, e: React.MouseEvent) => {
+ e.stopPropagation();
+
+ const confirmed = window.confirm('Withdraw from this race?');
+
+ if (!confirmed) return;
+
+ if (!currentDriverId) {
+ alert('You must be logged in to withdraw from races');
+ return;
+ }
+
+ try {
+ await withdrawMutation.mutateAsync({ raceId: race.id, driverId: currentDriverId });
+ } catch (err) {
+ alert(err instanceof Error ? err.message : 'Failed to withdraw');
+ }
+ };
return (
Schedule
-
+
+ {/* Filter Controls */}
+
+
+ {displayRaces.length} {displayRaces.length === 1 ? 'race' : 'races'}
+
+
+ setFilter('upcoming')}
+ className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
+ filter === 'upcoming'
+ ? 'bg-primary-blue text-white'
+ : 'bg-iron-gray text-gray-400 hover:text-white'
+ }`}
+ >
+ Upcoming ({upcomingRaces.length})
+
+ setFilter('past')}
+ className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
+ filter === 'past'
+ ? 'bg-primary-blue text-white'
+ : 'bg-iron-gray text-gray-400 hover:text-white'
+ }`}
+ >
+ Past ({pastRaces.length})
+
+ setFilter('all')}
+ className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
+ filter === 'all'
+ ? 'bg-primary-blue text-white'
+ : 'bg-iron-gray text-gray-400 hover:text-white'
+ }`}
+ >
+ All ({races.length})
+
+
+
+
+ {/* Race List */}
+ {displayRaces.length === 0 ? (
+
+
No {filter} races
+ {filter === 'upcoming' && (
+
Schedule your first race to get started
+ )}
+
+ ) : (
+
+ {displayRaces.map((race) => {
+ const isPast = race.isPast;
+ const isUpcoming = race.isUpcoming;
+ const isRegistered = Boolean(race.isRegistered);
+ const trackLabel = race.track ?? race.name;
+ const carLabel = race.car ?? '—';
+ const sessionTypeLabel = (race.sessionType ?? 'race').toLowerCase();
+ const isProcessing =
+ registerMutation.isPending || withdrawMutation.isPending;
+
+ return (
+
router.push(`/races/${race.id}`)}
+ >
+
+
+
+
{trackLabel}
+ {isUpcoming && !isRegistered && (
+
+ Upcoming
+
+ )}
+ {isUpcoming && isRegistered && (
+
+ ✓ Registered
+
+ )}
+ {isPast && (
+
+ Completed
+
+ )}
+
+
{carLabel}
+
+
+
+
+
+
+ {race.scheduledAt.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ })}
+
+
+ {race.scheduledAt.toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+ {isPast && race.status === 'completed' && (
+
View Results →
+ )}
+
+
+ {/* Registration Actions */}
+ {isUpcoming && (
+
e.stopPropagation()}>
+ {!isRegistered ? (
+ handleRegister(race, e)}
+ disabled={isProcessing}
+ className="px-3 py-1.5 text-sm font-medium bg-primary-blue hover:bg-primary-blue/80 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
+ >
+ {registerMutation.isPending ? 'Registering...' : 'Register'}
+
+ ) : (
+ handleWithdraw(race, e)}
+ disabled={isProcessing}
+ className="px-3 py-1.5 text-sm font-medium bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
+ >
+ {withdrawMutation.isPending ? 'Withdrawing...' : 'Withdraw'}
+
+ )}
+
+ )}
+
+
+
+ );
+ })}
+
+ )}
);
diff --git a/apps/website/templates/LeaguesTemplate.tsx b/apps/website/templates/LeaguesTemplate.tsx
index 4603d42b3..8d9ba182f 100644
--- a/apps/website/templates/LeaguesTemplate.tsx
+++ b/apps/website/templates/LeaguesTemplate.tsx
@@ -58,18 +58,16 @@ interface LeagueSliderProps {
icon: React.ElementType;
description: string;
leagues: LeagueSummaryViewModel[];
- onLeagueClick: (id: string) => void;
autoScroll?: boolean;
iconColor?: string;
scrollSpeedMultiplier?: number;
scrollDirection?: 'left' | 'right';
}
+import Link from 'next/link';
+
interface LeaguesTemplateProps {
- leagues: LeagueSummaryViewModel[];
- loading?: boolean;
- onLeagueClick: (id: string) => void;
- onCreateLeagueClick: () => void;
+ data: LeagueSummaryViewModel[];
}
// ============================================================================
@@ -183,7 +181,6 @@ function LeagueSlider({
icon: Icon,
description,
leagues,
- onLeagueClick,
autoScroll = true,
iconColor = 'text-primary-blue',
scrollSpeedMultiplier = 1,
@@ -372,7 +369,9 @@ function LeagueSlider({
`}
{leagues.map((league) => (
- onLeagueClick(league.id)} />
+
+
+
))}
@@ -386,17 +385,14 @@ function LeagueSlider({
// ============================================================================
export function LeaguesTemplate({
- leagues,
- loading = false,
- onLeagueClick,
- onCreateLeagueClick,
+ data,
}: LeaguesTemplateProps) {
const [searchQuery, setSearchQuery] = useState('');
const [activeCategory, setActiveCategory] = useState('all');
const [showFilters, setShowFilters] = useState(false);
// Filter by search query
- const searchFilteredLeagues = leagues.filter((league) => {
+ const searchFilteredLeagues = data.filter((league: LeagueSummaryViewModel) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
@@ -416,7 +412,7 @@ export function LeaguesTemplate({
const leaguesByCategory = CATEGORIES.reduce(
(acc, category) => {
// First try to use the dedicated category field, fall back to scoring-based filtering
- acc[category.id] = searchFilteredLeagues.filter((league) => {
+ acc[category.id] = searchFilteredLeagues.filter((league: LeagueSummaryViewModel) => {
// If league has a category field, use it directly
if (league.category) {
return league.category === category.id;
@@ -440,19 +436,6 @@ export function LeaguesTemplate({
{ id: 'sprint', speed: 1.2, direction: 'right' },
];
- if (loading) {
- return (
-
- );
- }
-
return (
{/* Hero Section */}
@@ -480,7 +463,7 @@ export function LeaguesTemplate({
- {leagues.length} active leagues
+ {data.length} active leagues
@@ -500,14 +483,10 @@ export function LeaguesTemplate({
{/* CTA */}
-
+
Create League
-
+
Set up your own racing series
@@ -574,7 +553,7 @@ export function LeaguesTemplate({
{/* Content */}
- {leagues.length === 0 ? (
+ {data.length === 0 ? (
/* Empty State */
@@ -587,14 +566,10 @@ export function LeaguesTemplate({
Be the first to create a racing series. Start your own league and invite drivers to compete for glory.
-
+
Create Your First League
-
+
) : activeCategory === 'all' && !searchQuery ? (
@@ -613,7 +588,6 @@ export function LeaguesTemplate({
icon={category.icon}
description={category.description}
leagues={leaguesByCategory[category.id]}
- onLeagueClick={onLeagueClick}
autoScroll={true}
iconColor={category.color || 'text-primary-blue'}
scrollSpeedMultiplier={speed}
@@ -640,7 +614,9 @@ export function LeaguesTemplate({
{categoryFilteredLeagues.map((league) => (
- onLeagueClick(league.id)} />
+
+
+
))}
>
diff --git a/apps/website/templates/SponsorLeagueDetailTemplate.tsx b/apps/website/templates/SponsorLeagueDetailTemplate.tsx
new file mode 100644
index 000000000..179ace101
--- /dev/null
+++ b/apps/website/templates/SponsorLeagueDetailTemplate.tsx
@@ -0,0 +1,578 @@
+'use client';
+
+import { useState } from 'react';
+import { motion, useReducedMotion } from 'framer-motion';
+import Link from 'next/link';
+import Card from '@/components/ui/Card';
+import Button from '@/components/ui/Button';
+import { siteConfig } from '@/lib/siteConfig';
+import {
+ Trophy,
+ Users,
+ Calendar,
+ Eye,
+ TrendingUp,
+ Download,
+ Image as ImageIcon,
+ ExternalLink,
+ ChevronRight,
+ Star,
+ Clock,
+ CheckCircle2,
+ Flag,
+ Car,
+ BarChart3,
+ ArrowUpRight,
+ Megaphone,
+ CreditCard,
+ FileText
+} from 'lucide-react';
+
+interface SponsorLeagueDetailData {
+ league: {
+ id: string;
+ name: string;
+ game: string;
+ season: string;
+ description: string;
+ tier: 'premium' | 'standard' | 'starter';
+ rating: number;
+ drivers: number;
+ races: number;
+ completedRaces: number;
+ racesLeft: number;
+ engagement: number;
+ totalImpressions: number;
+ formattedTotalImpressions: string;
+ projectedTotal: number;
+ formattedProjectedTotal: string;
+ mainSponsorCpm: number;
+ formattedMainSponsorCpm: string;
+ avgViewsPerRace: number;
+ formattedAvgViewsPerRace: string;
+ nextRace?: {
+ name: string;
+ date: string;
+ };
+ sponsorSlots: {
+ main: {
+ available: boolean;
+ price: number;
+ benefits: string[];
+ };
+ secondary: {
+ available: number;
+ total: number;
+ price: number;
+ benefits: string[];
+ };
+ };
+ tierConfig: {
+ bgColor: string;
+ color: string;
+ border: string;
+ };
+ };
+ drivers: Array<{
+ id: string;
+ position: number;
+ name: string;
+ team: string;
+ country: string;
+ races: number;
+ impressions: number;
+ formattedImpressions: string;
+ }>;
+ races: Array<{
+ id: string;
+ name: string;
+ date: string;
+ formattedDate: string;
+ status: 'completed' | 'upcoming';
+ views: number;
+ }>;
+}
+
+interface SponsorLeagueDetailTemplateProps {
+ data: SponsorLeagueDetailData;
+}
+
+type TabType = 'overview' | 'drivers' | 'races' | 'sponsor';
+
+export function SponsorLeagueDetailTemplate({ data }: SponsorLeagueDetailTemplateProps) {
+ const shouldReduceMotion = useReducedMotion();
+ const [activeTab, setActiveTab] = useState('overview');
+ const [selectedTier, setSelectedTier] = useState<'main' | 'secondary'>('main');
+
+ const league = data.league;
+ const config = league.tierConfig;
+
+ return (
+
+ {/* Breadcrumb */}
+
+ Dashboard
+
+ Leagues
+
+ {league.name}
+
+
+ {/* Header */}
+
+
+
+
+ ⭐ {league.tier}
+
+
+ Active Season
+
+
+
+ {league.rating}
+
+
+
{league.name}
+
{league.game} • {league.season} • {league.completedRaces}/{league.races} races completed
+
{league.description}
+
+
+
+
+
+
+ View League
+
+
+ {(league.sponsorSlots.main.available || league.sponsorSlots.secondary.available > 0) && (
+ setActiveTab('sponsor')}>
+
+ Become a Sponsor
+
+ )}
+
+
+
+ {/* Quick Stats */}
+
+
+
+
+
+
+
+
+
{league.formattedTotalImpressions}
+
Total Views
+
+
+
+
+
+
+
+
+
+
+
+
{league.formattedAvgViewsPerRace}
+
Avg/Race
+
+
+
+
+
+
+
+
+
+
+
+
{league.drivers}
+
Drivers
+
+
+
+
+
+
+
+
+
+
+
+
{league.engagement}%
+
Engagement
+
+
+
+
+
+
+
+
+
+
+
+
{league.racesLeft}
+
Races Left
+
+
+
+
+
+
+ {/* Tabs */}
+
+ {(['overview', 'drivers', 'races', 'sponsor'] as const).map((tab) => (
+ setActiveTab(tab)}
+ className={`px-4 py-3 text-sm font-medium capitalize transition-colors border-b-2 -mb-px whitespace-nowrap ${
+ activeTab === tab
+ ? 'text-primary-blue border-primary-blue'
+ : 'text-gray-400 border-transparent hover:text-white'
+ }`}
+ >
+ {tab === 'sponsor' ? '🎯 Become a Sponsor' : tab}
+
+ ))}
+
+
+ {/* Tab Content */}
+ {activeTab === 'overview' && (
+
+
+
+
+ League Information
+
+
+
+ Platform
+ {league.game}
+
+
+ Season
+ {league.season}
+
+
+ Duration
+ Oct 2025 - Feb 2026
+
+
+ Drivers
+ {league.drivers}
+
+
+ Races
+ {league.races}
+
+
+
+
+
+
+
+ Sponsorship Value
+
+
+
+ Total Season Views
+ {league.formattedTotalImpressions}
+
+
+ Projected Total
+ {league.formattedProjectedTotal}
+
+
+ Main Sponsor CPM
+
+ {league.formattedMainSponsorCpm}
+
+
+
+ Engagement Rate
+ {league.engagement}%
+
+
+
League Rating
+
+
+ {league.rating}/5.0
+
+
+
+
+
+ {/* Next Race */}
+ {league.nextRace && (
+
+
+
+ Next Race
+
+
+
+
+
+
+
+
{league.nextRace.name}
+
{league.nextRace.date}
+
+
+
+ View Schedule
+
+
+
+ )}
+
+ )}
+
+ {activeTab === 'drivers' && (
+
+
+
Championship Standings
+
Top drivers carrying sponsor branding
+
+
+ {data.drivers.map((driver) => (
+
+
+
+ {driver.position}
+
+
+
{driver.name}
+
{driver.team} • {driver.country}
+
+
+
+
+
{driver.races}
+
races
+
+
+
{driver.formattedImpressions}
+
views
+
+
+
+ ))}
+
+
+ )}
+
+ {activeTab === 'races' && (
+
+
+
Race Calendar
+
Season schedule with view statistics
+
+
+ {data.races.map((race) => (
+
+
+
+
+
{race.name}
+
{race.formattedDate}
+
+
+
+ {race.status === 'completed' ? (
+
+
{race.views.toLocaleString()}
+
views
+
+ ) : (
+
+ Upcoming
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ {activeTab === 'sponsor' && (
+
+ {/* Tier Selection */}
+
+ {/* Main Sponsor */}
+
league.sponsorSlots.main.available && setSelectedTier('main')}
+ >
+
+
+
+
+
Main Sponsor
+
+
Primary branding position
+
+ {league.sponsorSlots.main.available ? (
+
+ Available
+
+ ) : (
+
+ Filled
+
+ )}
+
+
+
+ ${league.sponsorSlots.main.price}
+ /season
+
+
+
+ {league.sponsorSlots.main.benefits.map((benefit: string, i: number) => (
+
+
+ {benefit}
+
+ ))}
+
+
+ {selectedTier === 'main' && league.sponsorSlots.main.available && (
+
+
+
+ )}
+
+
+ {/* Secondary Sponsor */}
+
league.sponsorSlots.secondary.available > 0 && setSelectedTier('secondary')}
+ >
+
+
+
+
+
Secondary Sponsor
+
+
Supporting branding position
+
+ {league.sponsorSlots.secondary.available > 0 ? (
+
+ {league.sponsorSlots.secondary.available}/{league.sponsorSlots.secondary.total} Available
+
+ ) : (
+
+ Full
+
+ )}
+
+
+
+ ${league.sponsorSlots.secondary.price}
+ /season
+
+
+
+ {league.sponsorSlots.secondary.benefits.map((benefit: string, i: number) => (
+
+
+ {benefit}
+
+ ))}
+
+
+ {selectedTier === 'secondary' && league.sponsorSlots.secondary.available > 0 && (
+
+
+
+ )}
+
+
+
+ {/* Checkout Summary */}
+
+
+
+ Sponsorship Summary
+
+
+
+
+ Selected Tier
+ {selectedTier} Sponsor
+
+
+ Season Price
+
+ ${selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price}
+
+
+
+ Platform Fee ({siteConfig.fees.platformFeePercent}%)
+
+ ${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * siteConfig.fees.platformFeePercent / 100).toFixed(2)}
+
+
+
+ Total (excl. VAT)
+
+ ${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * (1 + siteConfig.fees.platformFeePercent / 100)).toFixed(2)}
+
+
+
+
+
+ {siteConfig.vat.notice}
+
+
+
+
+
+ Request Sponsorship
+
+
+
+ Download Info Pack
+
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/app/sponsor/leagues/SponsorLeaguesInteractive.tsx b/apps/website/templates/SponsorLeaguesTemplate.tsx
similarity index 91%
rename from apps/website/app/sponsor/leagues/SponsorLeaguesInteractive.tsx
rename to apps/website/templates/SponsorLeaguesTemplate.tsx
index 4fe7beb0c..6df84e16b 100644
--- a/apps/website/app/sponsor/leagues/SponsorLeaguesInteractive.tsx
+++ b/apps/website/templates/SponsorLeaguesTemplate.tsx
@@ -1,15 +1,14 @@
'use client';
-import { useState } from 'react';
+import { useState, useMemo } from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { siteConfig } from '@/lib/siteConfig';
-import { useAvailableLeagues } from '@/hooks/sponsor/useAvailableLeagues';
-import {
- Trophy,
- Users,
+import {
+ Trophy,
+ Users,
Eye,
Search,
Star,
@@ -37,13 +36,31 @@ interface AvailableLeague {
nextRace?: string;
seasonStatus: 'active' | 'upcoming' | 'completed';
description: string;
+ formattedAvgViews: string;
+ formattedCpm: string;
+ cpm: number;
+ tierConfig: any;
+ statusConfig: any;
}
type SortOption = 'rating' | 'drivers' | 'price' | 'views';
type TierFilter = 'all' | 'premium' | 'standard' | 'starter';
type AvailabilityFilter = 'all' | 'main' | 'secondary';
-function LeagueCard({ league, index }: { league: any; index: number }) {
+interface SponsorLeaguesTemplateProps {
+ data: {
+ leagues: AvailableLeague[];
+ stats: {
+ total: number;
+ mainAvailable: number;
+ secondaryAvailable: number;
+ totalDrivers: number;
+ avgCpm: number;
+ };
+ };
+}
+
+function LeagueCard({ league, index }: { league: AvailableLeague; index: number }) {
const shouldReduceMotion = useReducedMotion();
const tierConfig = {
@@ -189,38 +206,17 @@ function LeagueCard({ league, index }: { league: any; index: number }) {
);
}
-export default function SponsorLeaguesInteractive() {
+export function SponsorLeaguesTemplate({ data }: SponsorLeaguesTemplateProps) {
const shouldReduceMotion = useReducedMotion();
- const { data, isLoading, isError, error } = useAvailableLeagues();
+
const [searchQuery, setSearchQuery] = useState('');
const [tierFilter, setTierFilter] = useState('all');
const [availabilityFilter, setAvailabilityFilter] = useState('all');
const [sortBy, setSortBy] = useState('rating');
- if (isLoading) {
- return (
-
- );
- }
-
- if (isError || !data) {
- return (
-
-
-
{error?.message || 'No leagues data available'}
-
-
- );
- }
-
// Filter and sort leagues
const filteredLeagues = data.leagues
- .filter(league => {
+ .filter((league: any) => {
if (searchQuery && !league.name.toLowerCase().includes(searchQuery.toLowerCase())) {
return false;
}
@@ -235,7 +231,7 @@ export default function SponsorLeaguesInteractive() {
}
return true;
})
- .sort((a, b) => {
+ .sort((a: any, b: any) => {
switch (sortBy) {
case 'rating': return b.rating - a.rating;
case 'drivers': return b.drivers - a.drivers;
@@ -245,16 +241,7 @@ export default function SponsorLeaguesInteractive() {
}
});
- // Calculate summary stats
- const stats = {
- total: data.leagues.length,
- mainAvailable: data.leagues.filter(l => l.mainSponsorSlot.available).length,
- secondaryAvailable: data.leagues.reduce((sum, l) => sum + l.secondarySlots.available, 0),
- totalDrivers: data.leagues.reduce((sum, l) => sum + l.drivers, 0),
- avgCpm: Math.round(
- data.leagues.reduce((sum, l) => sum + l.cpm, 0) / data.leagues.length
- ),
- };
+ const stats = data.stats;
return (
@@ -403,7 +390,7 @@ export default function SponsorLeaguesInteractive() {
{/* League Grid */}
{filteredLeagues.length > 0 ? (
- {filteredLeagues.map((league, index) => (
+ {filteredLeagues.map((league: any, index: number) => (
))}
diff --git a/apps/website/templates/SponsorshipRequestsTemplate.tsx b/apps/website/templates/SponsorshipRequestsTemplate.tsx
new file mode 100644
index 000000000..ebf9e480a
--- /dev/null
+++ b/apps/website/templates/SponsorshipRequestsTemplate.tsx
@@ -0,0 +1,158 @@
+'use client';
+
+import Breadcrumbs from '@/components/layout/Breadcrumbs';
+import PendingSponsorshipRequests from '@/components/sponsors/PendingSponsorshipRequests';
+import Card from '@/components/ui/Card';
+import { AlertTriangle, Building, ChevronRight, Handshake, Trophy, User, Users } from 'lucide-react';
+import Link from 'next/link';
+
+export interface EntitySection {
+ entityType: 'driver' | 'team' | 'race' | 'season';
+ entityId: string;
+ entityName: string;
+ requests: any[];
+}
+
+export interface SponsorshipRequestsTemplateProps {
+ data: EntitySection[];
+ onAccept: (requestId: string) => Promise
;
+ onReject: (requestId: string, reason?: string) => Promise;
+}
+
+export function SponsorshipRequestsTemplate({ data, onAccept, onReject }: SponsorshipRequestsTemplateProps) {
+ const totalRequests = data.reduce((sum, s) => sum + s.requests.length, 0);
+
+ const getEntityIcon = (type: 'driver' | 'team' | 'race' | 'season') => {
+ switch (type) {
+ case 'driver':
+ return User;
+ case 'team':
+ return Users;
+ case 'race':
+ return Trophy;
+ case 'season':
+ return Trophy;
+ default:
+ return Building;
+ }
+ };
+
+ const getEntityLink = (type: 'driver' | 'team' | 'race' | 'season', id: string) => {
+ switch (type) {
+ case 'driver':
+ return `/drivers/${id}`;
+ case 'team':
+ return `/teams/${id}`;
+ case 'race':
+ return `/races/${id}`;
+ case 'season':
+ return `/leagues/${id}/sponsorships`;
+ default:
+ return '#';
+ }
+ };
+
+ return (
+
+
+
+ {/* Header */}
+
+
+
+
+
+
Sponsorship Requests
+
+ Manage sponsorship requests for your profile, teams, and leagues
+
+
+ {totalRequests > 0 && (
+
+ {totalRequests} pending
+
+ )}
+
+
+ {data.length === 0 ? (
+
+
+
+
+
+
No Pending Requests
+
+ You don't have any pending sponsorship requests at the moment.
+
+
+ Sponsors can apply to sponsor your profile, teams, or leagues you manage.
+
+
+
+ ) : (
+
+ {data.map((section) => {
+ const Icon = getEntityIcon(section.entityType);
+ const entityLink = getEntityLink(section.entityType, section.entityId);
+
+ return (
+
+ {/* Section Header */}
+
+
+
+
+
+
+
{section.entityName}
+
{section.entityType}
+
+
+
+ View {section.entityType === 'season' ? 'Sponsorships' : section.entityType}
+
+
+
+
+ {/* Requests */}
+
+
+ );
+ })}
+
+ )}
+
+ {/* Info Card */}
+
+
+
+
+
+
+
How Sponsorships Work
+
+ Sponsors can apply to sponsor your driver profile, teams you manage, or leagues you administer.
+ Review each request carefully - accepting will activate the sponsorship and the sponsor will be
+ charged. You'll receive the payment minus a 10% platform fee.
+
+
+
+
+
+ );
+}
\ No newline at end of file