page wrapper
This commit is contained in:
@@ -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 <Loading />;
|
||||
if (error) return <Error error={error} />;
|
||||
return <div>{data}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### 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 <Loading />;
|
||||
if (isError) return <Error error={error} />;
|
||||
return <div>{data}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 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.
|
||||
@@ -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 (
|
||||
<html>
|
||||
<body>
|
||||
<ContainerProvider>
|
||||
{children}
|
||||
</ContainerProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 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 <ClientComponent league={league} />;
|
||||
}
|
||||
```
|
||||
|
||||
### 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(
|
||||
<ContainerProvider container={container}>
|
||||
<MyComponent />
|
||||
</ContainerProvider>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## 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>(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`
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
87
apps/website/lib/page/PageDataFetcher.ts
Normal file
87
apps/website/lib/page/PageDataFetcher.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
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 }] 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
|
||||
};
|
||||
}
|
||||
}
|
||||
149
apps/website/lib/page/usePageData.ts
Normal file
149
apps/website/lib/page/usePageData.ts
Normal file
@@ -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<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>
|
||||
) {
|
||||
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<TData, TError>(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<T extends Record<string, any>>(
|
||||
queries: {
|
||||
[K in keyof T]: PageDataConfig<T[K]>;
|
||||
}
|
||||
) {
|
||||
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<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,
|
||||
};
|
||||
}
|
||||
28
apps/website/lib/services/home/getHomeData.ts
Normal file
28
apps/website/lib/services/home/getHomeData.ts
Normal file
@@ -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<SessionService>(SESSION_SERVICE_TOKEN);
|
||||
const landingService = container.get<LandingService>(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,
|
||||
};
|
||||
}
|
||||
105
apps/website/lib/transformers/RaceResultsDataTransformer.ts
Normal file
105
apps/website/lib/transformers/RaceResultsDataTransformer.ts
Normal file
@@ -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<string, number>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
15
apps/website/lib/utils.ts
Normal file
15
apps/website/lib/utils.ts
Normal file
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user