1003 lines
28 KiB
Markdown
1003 lines
28 KiB
Markdown
# Clean Architecture Plan: Unified Data Fetching with SOLID OOP
|
|
|
|
## Executive Summary
|
|
This plan eliminates file proliferation and establishes a unified, type-safe data fetching architecture that handles **all** real-world scenarios: SSR, CSR, complex state, mutations, and multi-service dependencies.
|
|
|
|
## Core Principles
|
|
|
|
### 1. Single Responsibility
|
|
- **Data Fetching**: `PageDataFetcher` (SSR) + `usePageData` (CSR)
|
|
- **State Management**: `PageWrapper` handles loading/error/empty states
|
|
- **Business Logic**: Service classes handle domain logic
|
|
- **UI Rendering**: Templates handle presentation
|
|
|
|
### 2. Open/Closed Principle
|
|
- Extend via composition, not modification
|
|
- Add new service methods without changing fetchers
|
|
- Support new patterns via strategy pattern
|
|
|
|
### 3. Dependency Inversion
|
|
- High-level modules depend on abstractions
|
|
- Use DI container for SSR
|
|
- Use hooks for CSR
|
|
|
|
---
|
|
|
|
## Architecture Components
|
|
|
|
### 1. Unified Data Fetcher (SSR) - UPDATED FOR REALITY
|
|
**File**: `lib/page/PageDataFetcher.ts`
|
|
|
|
```typescript
|
|
import { ContainerManager } from '@/lib/di/container';
|
|
|
|
export interface FetchResult<T> {
|
|
data: T | null;
|
|
errors: Record<string, Error>;
|
|
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<TService, TMethod extends keyof TService>(
|
|
ServiceToken: string | symbol,
|
|
method: TMethod,
|
|
...args: TService[TMethod] extends (...params: infer P) => Promise<infer R> ? P : never
|
|
): Promise<(TService[TMethod] extends (...params: any[]) => Promise<infer R> ? R : never) | null> {
|
|
try {
|
|
const container = ContainerManager.getInstance().getContainer();
|
|
const service = container.get<TService>(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<TData>(
|
|
serviceFactory: () => Promise<TData> | TData
|
|
): Promise<TData | null> {
|
|
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<T extends Record<string, any>>(
|
|
queries: T
|
|
): Promise<FetchResult<{ [K in keyof T]: T[K] }>> {
|
|
const results = {} as { [K in keyof T]: T[K] };
|
|
const errors = {} as Record<string, Error>;
|
|
|
|
const entries = await Promise.all(
|
|
Object.entries(queries).map(async ([key, query]) => {
|
|
try {
|
|
const result = await query();
|
|
return [key, { success: true, data: result }];
|
|
} catch (error) {
|
|
console.error(`Failed to fetch ${key}:`, error);
|
|
return [key, { success: false, error: error instanceof Error ? error : new Error(String(error)) }];
|
|
}
|
|
})
|
|
);
|
|
|
|
entries.forEach(([key, result]) => {
|
|
if (result.success) {
|
|
results[key as keyof T] = result.data;
|
|
} else {
|
|
errors[key] = result.error;
|
|
}
|
|
});
|
|
|
|
return {
|
|
data: results,
|
|
errors,
|
|
hasErrors: Object.keys(errors).length > 0
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. Client-Side Data Hook - UPDATED FOR REALITY
|
|
**File**: `lib/page/usePageData.ts`
|
|
|
|
```typescript
|
|
'use client';
|
|
|
|
import { useQuery, useQueries, UseQueryOptions, useMutation, UseMutationOptions } from '@tanstack/react-query';
|
|
import { useInject } from '@/lib/di/hooks/useInject';
|
|
import { ApiError } from '@/lib/api/base/ApiError';
|
|
|
|
export interface PageDataConfig<TData, TError = ApiError> {
|
|
queryKey: string[];
|
|
queryFn: () => Promise<TData>;
|
|
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<TData, TError = ApiError>(
|
|
config: PageDataConfig<TData, TError>
|
|
) {
|
|
return useQuery<TData, TError>({
|
|
queryKey: config.queryKey,
|
|
queryFn: config.queryFn,
|
|
enabled: config.enabled ?? true,
|
|
staleTime: config.staleTime ?? 1000 * 60 * 5,
|
|
onError: config.onError,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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<T extends Record<string, any>>(
|
|
queries: {
|
|
[K in keyof T]: PageDataConfig<T[K]>;
|
|
}
|
|
) {
|
|
const queryResults = useQueries({
|
|
queries: Object.entries(queries).map(([key, config]) => ({
|
|
queryKey: config.queryKey,
|
|
queryFn: config.queryFn,
|
|
enabled: config.enabled ?? true,
|
|
staleTime: config.staleTime ?? 1000 * 60 * 5,
|
|
onError: config.onError,
|
|
})),
|
|
});
|
|
|
|
// Combine results
|
|
const combined = {} as { [K in keyof T]: T[K] | null };
|
|
const keys = Object.keys(queries) as (keyof T)[];
|
|
|
|
keys.forEach((key, index) => {
|
|
combined[key] = queryResults[index].data ?? 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<TData, TVariables, TError = ApiError>(
|
|
mutationFn: (variables: TVariables) => Promise<TData>,
|
|
options?: Omit<UseMutationOptions<TData, TError, TVariables>, 'mutationFn'>
|
|
) {
|
|
return useMutation<TData, TError, TVariables>({
|
|
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<TData>(
|
|
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,
|
|
};
|
|
}
|
|
```
|
|
|
|
### 3. Universal Page Wrapper - UPDATED FOR SSR/CSR COMPATIBILITY
|
|
**File**: `components/shared/state/PageWrapper.tsx`
|
|
|
|
```typescript
|
|
import React from 'react';
|
|
import { ApiError } from '@/lib/api/base/ApiError';
|
|
import { LoadingWrapper } from './LoadingWrapper';
|
|
import { ErrorDisplay } from './ErrorDisplay';
|
|
import { EmptyState } from './EmptyState';
|
|
|
|
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?: React.ElementType;
|
|
title?: string;
|
|
description?: string;
|
|
action?: {
|
|
label: string;
|
|
onClick: () => void;
|
|
};
|
|
}
|
|
|
|
export interface PageWrapperProps<TData> {
|
|
/** 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?: React.ReactNode;
|
|
/** Additional CSS classes */
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* PageWrapper Component - SSR/CSR COMPATIBLE
|
|
*
|
|
* CRITICAL: This component is NOT marked 'use client' to work in SSR pages
|
|
* For CSR pages, use the wrapper version below
|
|
*
|
|
* Usage in SSR:
|
|
* ```typescript
|
|
* export default async function Page() {
|
|
* const data = await PageDataFetcher.fetch(...);
|
|
* return <PageWrapper data={data} Template={MyTemplate} />;
|
|
* }
|
|
* ```
|
|
*
|
|
* Usage in CSR:
|
|
* ```typescript
|
|
* export default function Page() {
|
|
* const { data, isLoading, error } = usePageData(...);
|
|
* return (
|
|
* <PageWrapperCSR
|
|
* data={data}
|
|
* isLoading={isLoading}
|
|
* error={error}
|
|
* Template={MyTemplate}
|
|
* />
|
|
* );
|
|
* }
|
|
* ```
|
|
*/
|
|
export function PageWrapper<TData>({
|
|
data,
|
|
isLoading = false,
|
|
error = null,
|
|
retry,
|
|
Template,
|
|
loading,
|
|
errorConfig,
|
|
empty,
|
|
children,
|
|
className = '',
|
|
}: PageWrapperProps<TData>) {
|
|
// 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 (
|
|
<LoadingWrapper
|
|
variant="full-screen"
|
|
message={loadingMessage}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Default to skeleton
|
|
return (
|
|
<div className={className}>
|
|
<LoadingWrapper
|
|
variant="skeleton"
|
|
message={loadingMessage}
|
|
skeletonCount={3}
|
|
/>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<div className={className}>
|
|
<ErrorDisplay
|
|
error={error as ApiError}
|
|
onRetry={retry}
|
|
variant="card"
|
|
title={cardTitle}
|
|
description={cardDescription}
|
|
/>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Default to full-screen
|
|
return (
|
|
<ErrorDisplay
|
|
error={error as ApiError}
|
|
onRetry={retry}
|
|
variant="full-screen"
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 3. Empty State
|
|
if (!data || (Array.isArray(data) && data.length === 0)) {
|
|
if (empty) {
|
|
const Icon = empty.icon;
|
|
const hasAction = empty.action && retry;
|
|
|
|
return (
|
|
<div className={className}>
|
|
<EmptyState
|
|
icon={Icon || require('lucide-react').Inbox}
|
|
title={empty.title || 'No data available'}
|
|
description={empty.description}
|
|
action={hasAction ? {
|
|
label: empty.action!.label,
|
|
onClick: empty.action!.onClick,
|
|
} : undefined}
|
|
variant="default"
|
|
/>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// If no empty config provided but data is empty, show nothing
|
|
return (
|
|
<div className={className}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 4. Success State - Render Template with data
|
|
return (
|
|
<div className={className}>
|
|
<Template data={data} />
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
* <StatefulPageWrapper
|
|
* data={data}
|
|
* isLoading={isLoading}
|
|
* error={error}
|
|
* retry={refetch}
|
|
* Template={ProfileTemplate}
|
|
* loading={{ variant: 'skeleton', message: 'Loading profile...' }}
|
|
* />
|
|
* );
|
|
* }
|
|
* ```
|
|
*/
|
|
export function StatefulPageWrapper<TData>({
|
|
data,
|
|
isLoading = false,
|
|
error = null,
|
|
retry,
|
|
Template,
|
|
loading,
|
|
errorConfig,
|
|
empty,
|
|
children,
|
|
className = '',
|
|
}: PageWrapperProps<TData>) {
|
|
// Same implementation but with 'use client' for CSR-specific features
|
|
return (
|
|
<PageWrapper
|
|
data={data}
|
|
isLoading={isLoading}
|
|
error={error}
|
|
retry={retry}
|
|
Template={Template}
|
|
loading={loading}
|
|
errorConfig={errorConfig}
|
|
empty={empty}
|
|
children={children}
|
|
className={className}
|
|
/>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Page Patterns
|
|
|
|
### Pattern 1: Simple SSR Page - UPDATED
|
|
**Use Case**: Dashboard, simple detail pages
|
|
|
|
```typescript
|
|
// app/dashboard/page.tsx
|
|
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
|
import { DashboardTemplate } from '@/templates/DashboardTemplate';
|
|
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
|
import { DASHBOARD_SERVICE_TOKEN } from '@/lib/di/tokens';
|
|
import type { DashboardService } from '@/lib/services/dashboard/DashboardService';
|
|
import { notFound } from 'next/navigation';
|
|
|
|
export default async function DashboardPage() {
|
|
// CRITICAL: PageWrapper is NOT marked 'use client' for SSR compatibility
|
|
const data = await PageDataFetcher.fetch<DashboardService, 'getDashboardOverview'>(
|
|
DASHBOARD_SERVICE_TOKEN,
|
|
'getDashboardOverview'
|
|
);
|
|
|
|
if (!data) notFound();
|
|
|
|
return <PageWrapper data={data} Template={DashboardTemplate} />;
|
|
}
|
|
```
|
|
|
|
**⚠️ SSR Reality Check:**
|
|
- `PageWrapper` must NOT have `'use client'` directive
|
|
- All state management happens in the template (which can be client component)
|
|
- No loading states during SSR (data is fetched before render)
|
|
- Error handling via `notFound()` or try/catch
|
|
|
|
### Pattern 2: SSR Page with Parameters
|
|
**Use Case**: Race detail, team detail, league detail
|
|
|
|
```typescript
|
|
// app/races/[id]/page.tsx
|
|
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
|
import { RaceDetailTemplate } from '@/templates/RaceDetailTemplate';
|
|
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
|
import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
|
import type { RaceService } from '@/lib/services/races/RaceService';
|
|
import { notFound } from 'next/navigation';
|
|
|
|
interface Props {
|
|
params: { id: string; };
|
|
}
|
|
|
|
export default async function RaceDetailPage({ params }: Props) {
|
|
if (!params.id) notFound();
|
|
|
|
const data = await PageDataFetcher.fetch<RaceService, 'getRaceDetail'>(
|
|
RACE_SERVICE_TOKEN,
|
|
'getRaceDetail',
|
|
params.id,
|
|
'' // currentDriverId (handled client-side)
|
|
);
|
|
|
|
if (!data) notFound();
|
|
|
|
// Transform if needed
|
|
const viewModel = transformRaceData(data);
|
|
|
|
return (
|
|
<PageWrapper
|
|
data={viewModel}
|
|
Template={RaceDetailTemplate}
|
|
loading={{ variant: 'skeleton', message: 'Loading race details...' }}
|
|
errorConfig={{ variant: 'full-screen' }}
|
|
empty={{
|
|
icon: require('lucide-react').Flag,
|
|
title: 'Race not found',
|
|
description: 'The race may have been cancelled or deleted',
|
|
action: { label: 'Back to Races', onClick: () => {} }
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Pattern 3: SSR Page with Multiple Dependencies
|
|
**Use Case**: Leagues page, complex dashboards
|
|
|
|
```typescript
|
|
// app/leagues/page.tsx
|
|
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
|
import { LeaguesTemplate } from '@/templates/LeaguesTemplate';
|
|
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
|
import { LeagueService } from '@/lib/services/leagues/LeagueService';
|
|
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
|
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
|
|
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
|
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
|
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
|
|
|
export default async function LeaguesPage() {
|
|
const data = await PageDataFetcher.fetchManual(async () => {
|
|
// Manual dependency creation
|
|
const baseUrl = getWebsiteApiBaseUrl();
|
|
const logger = new ConsoleLogger();
|
|
const errorReporter = new EnhancedErrorReporter(logger, {
|
|
showUserNotifications: true,
|
|
logToConsole: true,
|
|
reportToExternal: process.env.NODE_ENV === 'production',
|
|
});
|
|
|
|
const leaguesApiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
|
|
const driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger);
|
|
|
|
const service = new LeagueService(leaguesApiClient, driversApiClient);
|
|
return await service.getAllLeagues();
|
|
});
|
|
|
|
if (!data) notFound();
|
|
|
|
return <PageWrapper data={data} Template={LeaguesTemplate} />;
|
|
}
|
|
```
|
|
|
|
### Pattern 4: CSR Page with Single Query
|
|
**Use Case**: Profile page, settings page
|
|
|
|
```typescript
|
|
// app/profile/page.tsx
|
|
'use client';
|
|
|
|
import { StatefulPageWrapper } from '@/components/shared/state/PageWrapper';
|
|
import { ProfileTemplate } from '@/templates/ProfileTemplate';
|
|
import { usePageData } from '@/lib/page/usePageData';
|
|
import { useInject } from '@/lib/di/hooks/useInject';
|
|
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
|
|
import type { DriverService } from '@/lib/services/drivers/DriverService';
|
|
|
|
export default function ProfilePage() {
|
|
const driverService = useInject(DRIVER_SERVICE_TOKEN);
|
|
|
|
const { data, isLoading, error, refetch } = usePageData({
|
|
queryKey: ['profile'],
|
|
queryFn: () => driverService.getProfile(),
|
|
});
|
|
|
|
return (
|
|
<StatefulPageWrapper
|
|
data={data}
|
|
isLoading={isLoading}
|
|
error={error}
|
|
retry={refetch}
|
|
Template={ProfileTemplate}
|
|
loading={{ variant: 'skeleton', message: 'Loading profile...' }}
|
|
/>
|
|
);
|
|
}
|
|
```
|
|
|
|
**⚠️ CSR Reality Check:**
|
|
- Uses `StatefulPageWrapper` (client component)
|
|
- Gets loading/error states from `usePageData` hook
|
|
- Can handle mutations and re-fetching
|
|
- No SSR data available initially
|
|
|
|
### Pattern 5: CSR Page with Multiple Queries
|
|
**Use Case**: Race results with SOF, memberships
|
|
|
|
```typescript
|
|
// app/races/[id]/results/page.tsx
|
|
'use client';
|
|
|
|
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
|
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
|
|
import { usePageDataMultiple } from '@/lib/page/usePageData';
|
|
import { useInject } from '@/lib/di/hooks/useInject';
|
|
import { RACE_RESULTS_SERVICE_TOKEN } from '@/lib/di/tokens';
|
|
import { RaceResultsDataTransformer } from '@/lib/transformers/RaceResultsDataTransformer';
|
|
import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships';
|
|
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
|
import { useState } from 'react';
|
|
import { notFound } from 'next/navigation';
|
|
|
|
interface Props {
|
|
params: { id: string; };
|
|
}
|
|
|
|
export default function RaceResultsPage({ params }: Props) {
|
|
const raceId = params.id;
|
|
if (!raceId) notFound();
|
|
|
|
const raceResultsService = useInject(RACE_RESULTS_SERVICE_TOKEN);
|
|
const currentDriverId = useEffectiveDriverId() || '';
|
|
|
|
// Multiple queries
|
|
const { data: queries, isLoading, error, refetch } = usePageDataMultiple({
|
|
results: {
|
|
queryKey: ['raceResultsDetail', raceId, currentDriverId],
|
|
queryFn: () => raceResultsService.getResultsDetail(raceId, currentDriverId),
|
|
},
|
|
sof: {
|
|
queryKey: ['raceWithSOF', raceId],
|
|
queryFn: () => raceResultsService.getWithSOF(raceId),
|
|
},
|
|
});
|
|
|
|
// Additional data
|
|
const leagueName = queries?.results?.league?.name || '';
|
|
const { data: memberships } = useLeagueMemberships(leagueName, currentDriverId);
|
|
|
|
// Transform
|
|
const data = queries?.results && queries?.sof
|
|
? RaceResultsDataTransformer.transform(
|
|
queries.results,
|
|
queries.sof,
|
|
currentDriverId,
|
|
memberships
|
|
)
|
|
: undefined;
|
|
|
|
// Local state
|
|
const [importing, setImporting] = useState(false);
|
|
const [showImportForm, setShowImportForm] = useState(false);
|
|
|
|
const handleImport = async (results: any[]) => {
|
|
setImporting(true);
|
|
try {
|
|
// Import logic
|
|
await refetch();
|
|
} finally {
|
|
setImporting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<PageWrapper
|
|
data={data}
|
|
isLoading={isLoading}
|
|
error={error}
|
|
retry={refetch}
|
|
Template={({ data }) => (
|
|
<RaceResultsTemplate
|
|
data={data}
|
|
importing={importing}
|
|
showImportForm={showImportForm}
|
|
setShowImportForm={setShowImportForm}
|
|
onImportResults={handleImport}
|
|
// ... other props
|
|
/>
|
|
)}
|
|
loading={{ variant: 'skeleton', message: 'Loading results...' }}
|
|
empty={{
|
|
icon: require('lucide-react').Trophy,
|
|
title: 'No results available',
|
|
description: 'Race results will appear here once completed',
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Pattern 6: CSR Page with Mutations
|
|
**Use Case**: Registration, form submissions
|
|
|
|
```typescript
|
|
// app/races/[id]/page.tsx
|
|
'use client';
|
|
|
|
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
|
import { RaceDetailTemplate } from '@/templates/RaceDetailTemplate';
|
|
import { usePageData, usePageMutation } from '@/lib/page/usePageData';
|
|
import { useInject } from '@/lib/di/hooks/useInject';
|
|
import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
|
import type { RaceService } from '@/lib/services/races/RaceService';
|
|
import { notFound } from 'next/navigation';
|
|
|
|
interface Props {
|
|
params: { id: string; };
|
|
}
|
|
|
|
export default function RaceDetailPage({ params }: Props) {
|
|
const raceId = params.id;
|
|
if (!raceId) notFound();
|
|
|
|
const raceService = useInject(RACE_SERVICE_TOKEN);
|
|
|
|
// Query
|
|
const { data, isLoading, error, refetch } = usePageData({
|
|
queryKey: ['raceDetail', raceId],
|
|
queryFn: () => raceService.getRaceDetail(raceId, ''),
|
|
});
|
|
|
|
// Mutations
|
|
const registerMutation = usePageMutation(
|
|
({ raceId, leagueId, driverId }: { raceId: string; leagueId: string; driverId: string }) =>
|
|
raceService.registerForRace(raceId, leagueId, driverId),
|
|
{
|
|
onSuccess: () => refetch(),
|
|
}
|
|
);
|
|
|
|
const withdrawMutation = usePageMutation(
|
|
({ raceId, driverId }: { raceId: string; driverId: string }) =>
|
|
raceService.withdrawFromRace(raceId, driverId),
|
|
{
|
|
onSuccess: () => refetch(),
|
|
}
|
|
);
|
|
|
|
const handleRegister = () => {
|
|
if (!data?.league?.id) return;
|
|
registerMutation.mutate({
|
|
raceId,
|
|
leagueId: data.league.id,
|
|
driverId: '', // Get from auth
|
|
});
|
|
};
|
|
|
|
return (
|
|
<PageWrapper
|
|
data={data}
|
|
isLoading={isLoading}
|
|
error={error}
|
|
retry={refetch}
|
|
Template={({ data }) => (
|
|
<RaceDetailTemplate
|
|
data={data}
|
|
onRegister={handleRegister}
|
|
onWithdraw={() => withdrawMutation.mutate({ raceId, driverId: '' })}
|
|
mutationLoading={{
|
|
register: registerMutation.isPending,
|
|
withdraw: withdrawMutation.isPending,
|
|
}}
|
|
/>
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Pattern 7: Client-Only Pages with URL State
|
|
**Use Case**: Wizards, multi-step flows
|
|
|
|
```typescript
|
|
// app/leagues/create/page.tsx
|
|
'use client';
|
|
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import { CreateLeagueWizard } from '@/components/leagues/CreateLeagueWizard';
|
|
import { Section } from '@/components/ui/Section';
|
|
import { Container } from '@/components/ui/Container';
|
|
|
|
type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'stewarding' | 'review';
|
|
|
|
function normalizeStepName(raw: string | null): StepName {
|
|
const validSteps = ['basics', 'visibility', 'structure', 'schedule', 'scoring', 'stewarding', 'review'];
|
|
return (validSteps.includes(raw || '') ? raw : 'basics') as StepName;
|
|
}
|
|
|
|
export default function CreateLeaguePage() {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
|
|
const currentStep = normalizeStepName(searchParams?.get('step') || null);
|
|
|
|
const handleStepChange = (stepName: StepName) => {
|
|
const params = new URLSearchParams(searchParams?.toString() || '');
|
|
params.set('step', stepName);
|
|
const query = params.toString();
|
|
router.push(query ? `/leagues/create?${query}` : '/leagues/create');
|
|
};
|
|
|
|
return (
|
|
<Section>
|
|
<Container size="md">
|
|
<CreateLeagueWizard stepName={currentStep} onStepChange={handleStepChange} />
|
|
</Container>
|
|
</Section>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Service Layer Patterns
|
|
|
|
### Service Class Structure
|
|
```typescript
|
|
// lib/services/races/RaceService.ts
|
|
export class RaceService {
|
|
constructor(
|
|
private readonly apiClient: RaceApiClient,
|
|
private readonly logger: Logger
|
|
) {}
|
|
|
|
async getRaceDetail(raceId: string, currentDriverId: string): Promise<RaceDetailDTO> {
|
|
this.logger.info('Fetching race detail', { raceId, currentDriverId });
|
|
return await this.apiClient.getRaceDetail(raceId, currentDriverId);
|
|
}
|
|
|
|
async registerForRace(raceId: string, leagueId: string, driverId: string): Promise<void> {
|
|
await this.apiClient.register(raceId, leagueId, driverId);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Data Transformers
|
|
```typescript
|
|
// lib/transformers/RaceResultsDataTransformer.ts
|
|
export class RaceResultsDataTransformer {
|
|
static transform(
|
|
results: RaceResultsDTO,
|
|
sof: RaceSOFDTO,
|
|
currentDriverId: string,
|
|
memberships?: LeagueMembershipDTO[]
|
|
): RaceResultsViewModel {
|
|
return {
|
|
raceTrack: results.race.track,
|
|
raceScheduledAt: results.race.scheduledAt,
|
|
totalDrivers: results.results.length,
|
|
leagueName: results.league.name,
|
|
raceSOF: sof.sof,
|
|
results: results.results.map(r => ({
|
|
...r,
|
|
isCurrentUser: r.driverId === currentDriverId,
|
|
})),
|
|
penalties: results.penalties,
|
|
pointsSystem: results.pointsSystem,
|
|
fastestLapTime: results.fastestLapTime,
|
|
memberships,
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Migration Strategy
|
|
|
|
### Phase 1: Core Infrastructure (Complete)
|
|
- ✅ `PageWrapper` component
|
|
- ✅ `PageDataFetcher` class
|
|
- ✅ Basic page patterns
|
|
|
|
### Phase 2: Client-Side Infrastructure
|
|
- [ ] Create `usePageData` hooks
|
|
- [ ] Update `PageWrapper` for CSR
|
|
- [ ] Create mutation hooks
|
|
|
|
### Phase 3: Page Migration
|
|
- [ ] Migrate simple SSR pages (Dashboard, etc.)
|
|
- [ ] Migrate SSR pages with params (Race Detail, etc.)
|
|
- [ ] Migrate SSR pages with multiple deps (Leagues, etc.)
|
|
- [ ] Migrate CSR pages (Profile, etc.)
|
|
- [ ] Migrate complex CSR pages (Race Results, etc.)
|
|
- [ ] Migrate mutation pages (Registration, etc.)
|
|
- [ ] Migrate client-only pages (Wizards, etc.)
|
|
|
|
### Phase 4: Cleanup
|
|
- [ ] Delete obsolete wrapper files
|
|
- [ ] Verify all patterns work
|
|
- [ ] Update documentation
|
|
|
|
---
|
|
|
|
## Benefits
|
|
|
|
### ✅ SOLID Compliance
|
|
- **Single Responsibility**: Each component has one clear purpose
|
|
- **Open/Closed**: Easy to extend without modification
|
|
- **Liskov Substitution**: All patterns use same `PageWrapper` interface
|
|
- **Interface Segregation**: Small, focused interfaces
|
|
- **Dependency Inversion**: High-level modules depend on abstractions
|
|
|
|
### ✅ Clean & DRY
|
|
- No file proliferation
|
|
- Consistent patterns across all pages
|
|
- Reusable components and hooks
|
|
|
|
### ✅ Type-Safe
|
|
- Full TypeScript support
|
|
- Compile-time type checking
|
|
- IntelliSense support
|
|
|
|
### ✅ Maintainable
|
|
- Clear separation of concerns
|
|
- Easy to debug and test
|
|
- Simple to add new pages
|
|
|
|
### ✅ Comprehensive
|
|
- Handles ALL real-world scenarios
|
|
- SSR, CSR, mutations, complex state
|
|
- Single source of truth for data fetching
|
|
|
|
This plan provides complete guidance for every scenario in your codebase while maintaining clean OOP/SOLID principles. |