fix issues
This commit is contained in:
@@ -19,7 +19,7 @@ const DEFAULT_CONFIG_PATH = 'apps/api/src/config/features.config.ts';
|
|||||||
* Output: { 'sponsors.portal': 'enabled' }
|
* Output: { 'sponsors.portal': 'enabled' }
|
||||||
*/
|
*/
|
||||||
function flattenFeatures(
|
function flattenFeatures(
|
||||||
config: Record<string, any>,
|
config: Record<string, unknown>,
|
||||||
prefix: string = ''
|
prefix: string = ''
|
||||||
): FlattenedFeatures {
|
): FlattenedFeatures {
|
||||||
const flattened: FlattenedFeatures = {};
|
const flattened: FlattenedFeatures = {};
|
||||||
@@ -29,7 +29,7 @@ function flattenFeatures(
|
|||||||
|
|
||||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||||
// Recursively flatten nested objects
|
// Recursively flatten nested objects
|
||||||
Object.assign(flattened, flattenFeatures(value, fullKey));
|
Object.assign(flattened, flattenFeatures(value as Record<string, unknown>, fullKey));
|
||||||
} else if (isFeatureState(value)) {
|
} else if (isFeatureState(value)) {
|
||||||
// Assign feature state
|
// Assign feature state
|
||||||
flattened[fullKey] = value;
|
flattened[fullKey] = value;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { AuthProvider } from '@/lib/auth/AuthContext';
|
|||||||
import { FeatureFlagService } from '@/lib/feature/FeatureFlagService';
|
import { FeatureFlagService } from '@/lib/feature/FeatureFlagService';
|
||||||
import { FeatureFlagProvider } from '@/lib/feature/FeatureFlagProvider';
|
import { FeatureFlagProvider } from '@/lib/feature/FeatureFlagProvider';
|
||||||
import { ContainerProvider } from '@/lib/di/providers/ContainerProvider';
|
import { ContainerProvider } from '@/lib/di/providers/ContainerProvider';
|
||||||
|
import { QueryClientProvider } from '@/lib/providers/QueryClientProvider';
|
||||||
import { initializeGlobalErrorHandling } from '@/lib/infrastructure/GlobalErrorHandler';
|
import { initializeGlobalErrorHandling } from '@/lib/infrastructure/GlobalErrorHandler';
|
||||||
import { initializeApiLogger } from '@/lib/infrastructure/ApiRequestLogger';
|
import { initializeApiLogger } from '@/lib/infrastructure/ApiRequestLogger';
|
||||||
import { Metadata, Viewport } from 'next';
|
import { Metadata, Viewport } from 'next';
|
||||||
@@ -81,39 +82,41 @@ export default async function RootLayout({
|
|||||||
</head>
|
</head>
|
||||||
<body className="antialiased overflow-x-hidden">
|
<body className="antialiased overflow-x-hidden">
|
||||||
<ContainerProvider>
|
<ContainerProvider>
|
||||||
<AuthProvider>
|
<QueryClientProvider>
|
||||||
<FeatureFlagProvider flags={enabledFlags}>
|
<AuthProvider>
|
||||||
<NotificationProvider>
|
<FeatureFlagProvider flags={enabledFlags}>
|
||||||
<NotificationIntegration />
|
<NotificationProvider>
|
||||||
<EnhancedErrorBoundary enableDevOverlay={process.env.NODE_ENV === 'development'}>
|
<NotificationIntegration />
|
||||||
<header className="fixed top-0 left-0 right-0 z-50 bg-deep-graphite/80 backdrop-blur-sm border-b border-white/5">
|
<EnhancedErrorBoundary enableDevOverlay={process.env.NODE_ENV === 'development'}>
|
||||||
<div className="max-w-7xl mx-auto px-6 py-4">
|
<header className="fixed top-0 left-0 right-0 z-50 bg-deep-graphite/80 backdrop-blur-sm border-b border-white/5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="max-w-7xl mx-auto px-6 py-4">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center justify-between">
|
||||||
<Link href="/" className="inline-flex items-center">
|
<div className="flex items-center space-x-3">
|
||||||
<Image
|
<Link href="/" className="inline-flex items-center">
|
||||||
src="/images/logos/wordmark-rectangle-dark.svg"
|
<Image
|
||||||
alt="GridPilot"
|
src="/images/logos/wordmark-rectangle-dark.svg"
|
||||||
width={160}
|
alt="GridPilot"
|
||||||
height={30}
|
width={160}
|
||||||
className="h-6 w-auto md:h-8"
|
height={30}
|
||||||
priority
|
className="h-6 w-auto md:h-8"
|
||||||
/>
|
priority
|
||||||
</Link>
|
/>
|
||||||
<p className="hidden sm:block text-sm text-gray-400 font-light">
|
</Link>
|
||||||
Making league racing less chaotic
|
<p className="hidden sm:block text-sm text-gray-400 font-light">
|
||||||
</p>
|
Making league racing less chaotic
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
</header>
|
<div className="pt-16">{children}</div>
|
||||||
<div className="pt-16">{children}</div>
|
{/* Development Tools */}
|
||||||
{/* Development Tools */}
|
{process.env.NODE_ENV === 'development' && <DevToolbar />}
|
||||||
{process.env.NODE_ENV === 'development' && <DevToolbar />}
|
</EnhancedErrorBoundary>
|
||||||
</EnhancedErrorBoundary>
|
</NotificationProvider>
|
||||||
</NotificationProvider>
|
</FeatureFlagProvider>
|
||||||
</FeatureFlagProvider>
|
</AuthProvider>
|
||||||
</AuthProvider>
|
</QueryClientProvider>
|
||||||
</ContainerProvider>
|
</ContainerProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
49
apps/website/app/leaderboards/LeaderboardsPageWrapper.tsx
Normal file
49
apps/website/app/leaderboards/LeaderboardsPageWrapper.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import LeaderboardsTemplate from '@/templates/LeaderboardsTemplate';
|
||||||
|
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
|
||||||
|
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||||
|
|
||||||
|
interface LeaderboardsPageData {
|
||||||
|
drivers: DriverLeaderboardViewModel | null;
|
||||||
|
teams: TeamSummaryViewModel[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeaderboardsPageWrapper({ data }: { data: LeaderboardsPageData | null }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
if (!data || (!data.drivers && !data.teams)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drivers = data.drivers?.drivers || [];
|
||||||
|
const teams = data.teams || [];
|
||||||
|
|
||||||
|
const handleDriverClick = (driverId: string) => {
|
||||||
|
router.push(`/drivers/${driverId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTeamClick = (teamId: string) => {
|
||||||
|
router.push(`/teams/${teamId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNavigateToDrivers = () => {
|
||||||
|
router.push('/leaderboards/drivers');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNavigateToTeams = () => {
|
||||||
|
router.push('/teams/leaderboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LeaderboardsTemplate
|
||||||
|
drivers={drivers}
|
||||||
|
teams={teams}
|
||||||
|
onDriverClick={handleDriverClick}
|
||||||
|
onTeamClick={handleTeamClick}
|
||||||
|
onNavigateToDrivers={handleNavigateToDrivers}
|
||||||
|
onNavigateToTeams={handleNavigateToTeams}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import DriverRankingsTemplate from '@/templates/DriverRankingsTemplate';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
|
||||||
|
|
||||||
|
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||||
|
type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate';
|
||||||
|
|
||||||
|
export function DriverRankingsPageWrapper({ data }: { data: DriverLeaderboardViewModel | null }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Client-side state for filtering and sorting
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedSkill, setSelectedSkill] = useState<'all' | SkillLevel>('all');
|
||||||
|
const [sortBy, setSortBy] = useState<SortBy>('rank');
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
|
if (!data || !data.drivers) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDriverClick = (driverId: string) => {
|
||||||
|
if (driverId.startsWith('demo-')) return;
|
||||||
|
router.push(`/drivers/${driverId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackToLeaderboards = () => {
|
||||||
|
router.push('/leaderboards');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DriverRankingsTemplate
|
||||||
|
drivers={data.drivers}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
selectedSkill={selectedSkill}
|
||||||
|
sortBy={sortBy}
|
||||||
|
showFilters={showFilters}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
onSkillChange={setSelectedSkill}
|
||||||
|
onSortChange={setSortBy}
|
||||||
|
onToggleFilters={() => setShowFilters(!showFilters)}
|
||||||
|
onDriverClick={handleDriverClick}
|
||||||
|
onBackToLeaderboards={handleBackToLeaderboards}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,62 +1,11 @@
|
|||||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||||
import DriverRankingsTemplate from '@/templates/DriverRankingsTemplate';
|
|
||||||
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
||||||
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
|
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||||
import { DriverService } from '@/lib/services/drivers/DriverService';
|
import { DriverService } from '@/lib/services/drivers/DriverService';
|
||||||
import { Users } from 'lucide-react';
|
import { Users } from 'lucide-react';
|
||||||
import { redirect , useRouter } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
|
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
|
||||||
import { useState } from 'react';
|
import { DriverRankingsPageWrapper } from './DriverRankingsPageWrapper';
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TYPES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
|
||||||
type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// WRAPPER COMPONENT (Client-side state management)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function DriverRankingsPageWrapper({ data }: { data: DriverLeaderboardViewModel | null }) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// Client-side state for filtering and sorting
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [selectedSkill, setSelectedSkill] = useState<'all' | SkillLevel>('all');
|
|
||||||
const [sortBy, setSortBy] = useState<SortBy>('rank');
|
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
|
||||||
|
|
||||||
if (!data || !data.drivers) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDriverClick = (driverId: string) => {
|
|
||||||
if (driverId.startsWith('demo-')) return;
|
|
||||||
router.push(`/drivers/${driverId}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBackToLeaderboards = () => {
|
|
||||||
router.push('/leaderboards');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DriverRankingsTemplate
|
|
||||||
drivers={data.drivers}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
selectedSkill={selectedSkill}
|
|
||||||
sortBy={sortBy}
|
|
||||||
showFilters={showFilters}
|
|
||||||
onSearchChange={setSearchQuery}
|
|
||||||
onSkillChange={setSelectedSkill}
|
|
||||||
onSortChange={setSortBy}
|
|
||||||
onToggleFilters={() => setShowFilters(!showFilters)}
|
|
||||||
onDriverClick={handleDriverClick}
|
|
||||||
onBackToLeaderboards={handleBackToLeaderboards}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MAIN PAGE COMPONENT
|
// MAIN PAGE COMPONENT
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||||
import LeaderboardsTemplate from '@/templates/LeaderboardsTemplate';
|
|
||||||
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
||||||
import { DRIVER_SERVICE_TOKEN, TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
|
import { DRIVER_SERVICE_TOKEN, TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||||
import { DriverService } from '@/lib/services/drivers/DriverService';
|
import { DriverService } from '@/lib/services/drivers/DriverService';
|
||||||
import { TeamService } from '@/lib/services/teams/TeamService';
|
import { TeamService } from '@/lib/services/teams/TeamService';
|
||||||
import { Trophy } from 'lucide-react';
|
import { Trophy } from 'lucide-react';
|
||||||
import { redirect , useRouter } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
|
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
|
||||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||||
|
import { LeaderboardsPageWrapper } from './LeaderboardsPageWrapper';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@@ -18,48 +18,6 @@ interface LeaderboardsPageData {
|
|||||||
teams: TeamSummaryViewModel[] | null;
|
teams: TeamSummaryViewModel[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// WRAPPER COMPONENT
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function LeaderboardsPageWrapper({ data }: { data: LeaderboardsPageData | null }) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
if (!data || (!data.drivers && !data.teams)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const drivers = data.drivers?.drivers || [];
|
|
||||||
const teams = data.teams || [];
|
|
||||||
|
|
||||||
const handleDriverClick = (driverId: string) => {
|
|
||||||
router.push(`/drivers/${driverId}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTeamClick = (teamId: string) => {
|
|
||||||
router.push(`/teams/${teamId}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNavigateToDrivers = () => {
|
|
||||||
router.push('/leaderboards/drivers');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNavigateToTeams = () => {
|
|
||||||
router.push('/teams/leaderboard');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LeaderboardsTemplate
|
|
||||||
drivers={drivers}
|
|
||||||
teams={teams}
|
|
||||||
onDriverClick={handleDriverClick}
|
|
||||||
onTeamClick={handleTeamClick}
|
|
||||||
onNavigateToDrivers={handleNavigateToDrivers}
|
|
||||||
onNavigateToTeams={handleNavigateToTeams}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MAIN PAGE COMPONENT
|
// MAIN PAGE COMPONENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import TeamLeaderboardTemplate from '@/templates/TeamLeaderboardTemplate';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||||
|
|
||||||
|
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||||
|
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
|
||||||
|
|
||||||
|
export function TeamLeaderboardPageWrapper({ data }: { data: TeamSummaryViewModel[] | null }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Client-side state for filtering and sorting
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [filterLevel, setFilterLevel] = useState<SkillLevel | 'all'>('all');
|
||||||
|
const [sortBy, setSortBy] = useState<SortBy>('rating');
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTeamClick = (teamId: string) => {
|
||||||
|
router.push(`/teams/${teamId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackToTeams = () => {
|
||||||
|
router.push('/teams');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TeamLeaderboardTemplate
|
||||||
|
teams={data}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
filterLevel={filterLevel}
|
||||||
|
sortBy={sortBy}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
onFilterLevelChange={setFilterLevel}
|
||||||
|
onSortChange={setSortBy}
|
||||||
|
onTeamClick={handleTeamClick}
|
||||||
|
onBackToTeams={handleBackToTeams}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,58 +1,11 @@
|
|||||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||||
import TeamLeaderboardTemplate from '@/templates/TeamLeaderboardTemplate';
|
|
||||||
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
||||||
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
|
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||||
import { TeamService } from '@/lib/services/teams/TeamService';
|
import { TeamService } from '@/lib/services/teams/TeamService';
|
||||||
import { Trophy } from 'lucide-react';
|
import { Trophy } from 'lucide-react';
|
||||||
import { redirect , useRouter } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||||
import { useState } from 'react';
|
import { TeamLeaderboardPageWrapper } from './TeamLeaderboardPageWrapper';
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TYPES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
|
||||||
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// WRAPPER COMPONENT (Client-side state management)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function TeamLeaderboardPageWrapper({ data }: { data: TeamSummaryViewModel[] | null }) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// Client-side state for filtering and sorting
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [filterLevel, setFilterLevel] = useState<SkillLevel | 'all'>('all');
|
|
||||||
const [sortBy, setSortBy] = useState<SortBy>('rating');
|
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTeamClick = (teamId: string) => {
|
|
||||||
router.push(`/teams/${teamId}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBackToTeams = () => {
|
|
||||||
router.push('/teams');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TeamLeaderboardTemplate
|
|
||||||
teams={data}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
filterLevel={filterLevel}
|
|
||||||
sortBy={sortBy}
|
|
||||||
onSearchChange={setSearchQuery}
|
|
||||||
onFilterLevelChange={setFilterLevel}
|
|
||||||
onSortChange={setSortBy}
|
|
||||||
onTeamClick={handleTeamClick}
|
|
||||||
onBackToTeams={handleBackToTeams}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MAIN PAGE COMPONENT
|
// MAIN PAGE COMPONENT
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Container } from 'inversify';
|
|||||||
|
|
||||||
// Module imports
|
// Module imports
|
||||||
import { ApiModule } from './modules/api.module';
|
import { ApiModule } from './modules/api.module';
|
||||||
|
import { AuthModule } from './modules/auth.module';
|
||||||
import { CoreModule } from './modules/core.module';
|
import { CoreModule } from './modules/core.module';
|
||||||
import { DashboardModule } from './modules/dashboard.module';
|
import { DashboardModule } from './modules/dashboard.module';
|
||||||
import { DriverModule } from './modules/driver.module';
|
import { DriverModule } from './modules/driver.module';
|
||||||
@@ -23,6 +24,7 @@ export function createContainer(): Container {
|
|||||||
container.load(
|
container.load(
|
||||||
CoreModule,
|
CoreModule,
|
||||||
ApiModule,
|
ApiModule,
|
||||||
|
AuthModule,
|
||||||
LeagueModule,
|
LeagueModule,
|
||||||
DriverModule,
|
DriverModule,
|
||||||
TeamModule,
|
TeamModule,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export * from './providers/ContainerProvider';
|
|||||||
// Modules
|
// Modules
|
||||||
export * from './modules/analytics.module';
|
export * from './modules/analytics.module';
|
||||||
export * from './modules/api.module';
|
export * from './modules/api.module';
|
||||||
|
export * from './modules/auth.module';
|
||||||
export * from './modules/core.module';
|
export * from './modules/core.module';
|
||||||
export * from './modules/dashboard.module';
|
export * from './modules/dashboard.module';
|
||||||
export * from './modules/driver.module';
|
export * from './modules/driver.module';
|
||||||
|
|||||||
30
apps/website/lib/di/modules/auth.module.ts
Normal file
30
apps/website/lib/di/modules/auth.module.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { ContainerModule } from 'inversify';
|
||||||
|
import { AuthService } from '../../services/auth/AuthService';
|
||||||
|
import { SessionService } from '../../services/auth/SessionService';
|
||||||
|
import { AuthApiClient } from '../../api/auth/AuthApiClient';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AUTH_SERVICE_TOKEN,
|
||||||
|
SESSION_SERVICE_TOKEN,
|
||||||
|
AUTH_API_CLIENT_TOKEN
|
||||||
|
} from '../tokens';
|
||||||
|
|
||||||
|
export const AuthModule = new ContainerModule((options) => {
|
||||||
|
const bind = options.bind;
|
||||||
|
|
||||||
|
// Session Service
|
||||||
|
bind<SessionService>(SESSION_SERVICE_TOKEN)
|
||||||
|
.toDynamicValue((ctx) => {
|
||||||
|
const authApiClient = ctx.get<AuthApiClient>(AUTH_API_CLIENT_TOKEN);
|
||||||
|
return new SessionService(authApiClient);
|
||||||
|
})
|
||||||
|
.inSingletonScope();
|
||||||
|
|
||||||
|
// Auth Service
|
||||||
|
bind<AuthService>(AUTH_SERVICE_TOKEN)
|
||||||
|
.toDynamicValue((ctx) => {
|
||||||
|
const authApiClient = ctx.get<AuthApiClient>(AUTH_API_CLIENT_TOKEN);
|
||||||
|
return new AuthService(authApiClient);
|
||||||
|
})
|
||||||
|
.inSingletonScope();
|
||||||
|
});
|
||||||
@@ -91,6 +91,9 @@ export const LeagueModule = new ContainerModule((options) => {
|
|||||||
|
|
||||||
// League Membership Service
|
// League Membership Service
|
||||||
bind<LeagueMembershipService>(LEAGUE_MEMBERSHIP_SERVICE_TOKEN)
|
bind<LeagueMembershipService>(LEAGUE_MEMBERSHIP_SERVICE_TOKEN)
|
||||||
.to(LeagueMembershipService)
|
.toDynamicValue((ctx) => {
|
||||||
|
const leagueApiClient = ctx.get<LeaguesApiClient>(LEAGUE_API_CLIENT_TOKEN);
|
||||||
|
return new LeagueMembershipService(leagueApiClient);
|
||||||
|
})
|
||||||
.inSingletonScope();
|
.inSingletonScope();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,14 +35,29 @@ export class ConsoleLogger implements Logger {
|
|||||||
const emoji = this.EMOJIS[level];
|
const emoji = this.EMOJIS[level];
|
||||||
const prefix = this.PREFIXES[level];
|
const prefix = this.PREFIXES[level];
|
||||||
|
|
||||||
console.groupCollapsed(`%c${emoji} [${source.toUpperCase()}] ${prefix}: ${message}`, `color: ${color}; font-weight: bold;`);
|
// Edge runtime doesn't support console.groupCollapsed/groupEnd
|
||||||
|
// Fallback to simple logging for compatibility
|
||||||
|
const supportsGrouping = typeof console.groupCollapsed === 'function' && typeof console.groupEnd === 'function';
|
||||||
|
|
||||||
|
if (supportsGrouping) {
|
||||||
|
// Safe to call - we've verified both functions exist
|
||||||
|
(console as any).groupCollapsed(`%c${emoji} [${source.toUpperCase()}] ${prefix}: ${message}`, `color: ${color}; font-weight: bold;`);
|
||||||
|
} else {
|
||||||
|
// Simple format for edge runtime
|
||||||
|
console.log(`${emoji} [${source.toUpperCase()}] ${prefix}: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`%cTimestamp:`, 'color: #666; font-weight: bold;', new Date().toISOString());
|
console.log(`%cTimestamp:`, 'color: #666; font-weight: bold;', new Date().toISOString());
|
||||||
console.log(`%cSource:`, 'color: #666; font-weight: bold;', source);
|
console.log(`%cSource:`, 'color: #666; font-weight: bold;', source);
|
||||||
|
|
||||||
if (context) {
|
if (context) {
|
||||||
console.log(`%cContext:`, 'color: #666; font-weight: bold;');
|
console.log(`%cContext:`, 'color: #666; font-weight: bold;');
|
||||||
console.dir(context, { depth: 3, colors: true });
|
// console.dir may not be available in edge runtime
|
||||||
|
if (typeof console.dir === 'function') {
|
||||||
|
console.dir(context, { depth: 3, colors: true });
|
||||||
|
} else {
|
||||||
|
console.log(JSON.stringify(context, null, 2));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -56,7 +71,10 @@ export class ConsoleLogger implements Logger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.groupEnd();
|
if (supportsGrouping) {
|
||||||
|
// Safe to call - we've verified the function exists
|
||||||
|
(console as any).groupEnd();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debug(message: string, context?: unknown): void {
|
debug(message: string, context?: unknown): void {
|
||||||
|
|||||||
42
apps/website/lib/providers/QueryClientProvider.tsx
Normal file
42
apps/website/lib/providers/QueryClientProvider.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider as TanstackQueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { ReactNode, useState } from 'react';
|
||||||
|
|
||||||
|
interface QueryClientProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides React Query client to the application
|
||||||
|
*
|
||||||
|
* Must wrap any components that use React Query hooks (useQuery, useMutation, etc.)
|
||||||
|
* Creates a new QueryClient instance per component tree to avoid state sharing
|
||||||
|
*/
|
||||||
|
export function QueryClientProvider({ children }: QueryClientProviderProps) {
|
||||||
|
// Create a new QueryClient instance for each component tree
|
||||||
|
// This prevents state sharing between different renders
|
||||||
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
// Disable automatic refetching in production for better performance
|
||||||
|
refetchOnWindowFocus: process.env.NODE_ENV === 'development',
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
retry: 1,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TanstackQueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</TanstackQueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
225
docs/FEATURE_ARCHITECTURE.md
Normal file
225
docs/FEATURE_ARCHITECTURE.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# Feature Architecture: Modes vs Feature Flags
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
Your current system has two overlapping concepts that conflict:
|
||||||
|
- **Feature Flags** (`features.config.ts`) - Controls individual features
|
||||||
|
- **App Modes** (`NEXT_PUBLIC_GRIDPILOT_MODE`) - Controls overall platform visibility
|
||||||
|
|
||||||
|
This creates confusion because:
|
||||||
|
1. Development shows only landing page but feature config says everything is enabled
|
||||||
|
2. It's unclear which system controls what
|
||||||
|
3. Teams don't know when to use mode vs feature flag
|
||||||
|
|
||||||
|
## The Solution: Clear Separation
|
||||||
|
|
||||||
|
### **Two-Tier System**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ APP MODE (Tier 1) │
|
||||||
|
│ Controls WHAT the platform shows │
|
||||||
|
│ - pre-launch: Landing page only │
|
||||||
|
│ - alpha: Full platform access │
|
||||||
|
│ - beta: Production-ready features │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ FEATURE FLAGS (Tier 2) │
|
||||||
|
│ Controls WHICH features are enabled │
|
||||||
|
│ - Individual feature toggles │
|
||||||
|
│ - Rollout control │
|
||||||
|
│ - A/B testing │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Mode = Platform Scope**
|
||||||
|
|
||||||
|
**App Mode defines what the entire platform can do:**
|
||||||
|
|
||||||
|
- **`pre-launch`**: "We're not ready yet"
|
||||||
|
- Shows: Landing page, Discord CTA, FAQ
|
||||||
|
- Hides: All navigation, dashboard, leagues, teams, races
|
||||||
|
- Purpose: Marketing/teaser phase
|
||||||
|
|
||||||
|
- **`alpha`**: "Early access for testers"
|
||||||
|
- Shows: Everything + alpha badges
|
||||||
|
- Purpose: Internal testing, early adopters
|
||||||
|
- All features enabled by default
|
||||||
|
|
||||||
|
- **`beta`**: "Public beta"
|
||||||
|
- Shows: Production features only
|
||||||
|
- Purpose: Gradual rollout to real users
|
||||||
|
- Features controlled individually
|
||||||
|
|
||||||
|
### **Feature Flags = Feature Control**
|
||||||
|
|
||||||
|
**Feature flags control individual features within a mode:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In alpha mode, all features are ON by default
|
||||||
|
// But you can still disable specific ones for testing
|
||||||
|
{
|
||||||
|
"platform.dashboard": "enabled",
|
||||||
|
"platform.leagues": "enabled",
|
||||||
|
"platform.teams": "disabled", // Testing without teams
|
||||||
|
"sponsors.portal": "enabled",
|
||||||
|
"admin.dashboard": "enabled"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Simple Mental Model
|
||||||
|
|
||||||
|
### **For Developers: "The Restaurant Analogy"**
|
||||||
|
|
||||||
|
```
|
||||||
|
APP MODE = Restaurant State
|
||||||
|
├── "Closed" (pre-launch) → Only show entrance/menu
|
||||||
|
├── "Soft Opening" (alpha) → Full menu, everything available
|
||||||
|
└── "Grand Opening" (beta) → Full menu, but some items may be 86'd
|
||||||
|
|
||||||
|
FEATURE FLAGS = Menu Items
|
||||||
|
├── Each dish can be: Available / 86'd / Coming Soon
|
||||||
|
├── Works within whatever restaurant state you're in
|
||||||
|
└── Lets you control individual items precisely
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Decision Tree**
|
||||||
|
|
||||||
|
```
|
||||||
|
Question: "What should I use?"
|
||||||
|
│
|
||||||
|
├─ "Is the platform ready for ANY users?"
|
||||||
|
│ ├─ No → Use APP MODE = pre-launch
|
||||||
|
│ └─ Yes → Continue...
|
||||||
|
│
|
||||||
|
├─ "Are we in testing or production?"
|
||||||
|
│ ├─ Testing → Use APP MODE = alpha
|
||||||
|
│ └─ Production → Use APP MODE = beta
|
||||||
|
│
|
||||||
|
└─ "Do I need to control a specific feature?"
|
||||||
|
└─ Yes → Use FEATURE FLAGS (regardless of mode)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Rules
|
||||||
|
|
||||||
|
### **Rule 1: Mode Controls Visibility**
|
||||||
|
```typescript
|
||||||
|
// ❌ WRONG: Using feature flags to hide entire platform
|
||||||
|
{
|
||||||
|
"platform": {
|
||||||
|
"dashboard": "disabled",
|
||||||
|
"leagues": "disabled",
|
||||||
|
"teams": "disabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ CORRECT: Use mode for platform-wide visibility
|
||||||
|
// NEXT_PUBLIC_GRIDPILOT_MODE=pre-launch
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Rule 2: Feature Flags Control Granularity**
|
||||||
|
```typescript
|
||||||
|
// ✅ CORRECT: Feature flags for fine-grained control
|
||||||
|
{
|
||||||
|
"platform": {
|
||||||
|
"dashboard": "enabled",
|
||||||
|
"leagues": "enabled",
|
||||||
|
"teams": "enabled", // But specific team features...
|
||||||
|
"teams.create": "disabled", // ...can be toggled
|
||||||
|
"teams.delete": "coming_soon"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Rule 3: Alpha Mode = Auto-Enable**
|
||||||
|
```typescript
|
||||||
|
// In alpha mode, ALL features are enabled automatically
|
||||||
|
// Feature flags can only DISABLE, not enable
|
||||||
|
// This eliminates configuration complexity
|
||||||
|
|
||||||
|
// Feature flag service in alpha mode:
|
||||||
|
if (mode === 'alpha') {
|
||||||
|
return new FeatureFlagService([
|
||||||
|
'all', 'features', 'enabled', 'by', 'default'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
|
||||||
|
### **Current State (Confusing)**
|
||||||
|
```
|
||||||
|
Development:
|
||||||
|
- Mode: pre-launch (default)
|
||||||
|
- Features: All enabled in config
|
||||||
|
- Result: Shows landing page only ❌
|
||||||
|
|
||||||
|
Why? Because mode overrides feature config
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Target State (Clear)**
|
||||||
|
```
|
||||||
|
Development:
|
||||||
|
- Mode: alpha (explicit)
|
||||||
|
- Features: All auto-enabled
|
||||||
|
- Result: Full platform with alpha badges ✅
|
||||||
|
|
||||||
|
Production:
|
||||||
|
- Mode: beta
|
||||||
|
- Features: Controlled individually
|
||||||
|
- Result: Gradual rollout ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Examples
|
||||||
|
|
||||||
|
### **Simple Mode Config**
|
||||||
|
```typescript
|
||||||
|
// apps/website/.env.development
|
||||||
|
NEXT_PUBLIC_GRIDPILOT_MODE=alpha
|
||||||
|
|
||||||
|
// apps/website/.env.production
|
||||||
|
NEXT_PUBLIC_GRIDPILOT_MODE=beta
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Feature Flags (Only When Needed)**
|
||||||
|
```typescript
|
||||||
|
// Only override defaults when necessary
|
||||||
|
// apps/api/src/config/features.config.ts
|
||||||
|
{
|
||||||
|
production: {
|
||||||
|
platform: {
|
||||||
|
dashboard: 'enabled',
|
||||||
|
leagues: 'enabled',
|
||||||
|
teams: 'enabled',
|
||||||
|
races: 'enabled',
|
||||||
|
leaderboards: 'enabled'
|
||||||
|
},
|
||||||
|
sponsors: {
|
||||||
|
portal: 'enabled',
|
||||||
|
dashboard: 'enabled',
|
||||||
|
management: 'disabled' // Not ready yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **No Confusion**: Clear separation of concerns
|
||||||
|
2. **Less Config**: Alpha mode needs zero feature config
|
||||||
|
3. **Easy Onboarding**: New devs just set mode=alpha
|
||||||
|
4. **Powerful Control**: Feature flags still available when needed
|
||||||
|
5. **Backward Compatible**: Existing code works with new concept
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Scenario | Mode | Feature Flags | Result |
|
||||||
|
|----------|------|---------------|--------|
|
||||||
|
| Local dev | `alpha` | None needed | Full platform |
|
||||||
|
| CI/CD tests | `alpha` | None needed | Full platform |
|
||||||
|
| Staging | `beta` | Some disabled | Controlled rollout |
|
||||||
|
| Production | `beta` | Gradual enable | Public launch |
|
||||||
|
| Marketing site | `pre-launch` | N/A | Landing page only |
|
||||||
|
|
||||||
|
This eliminates the contradiction and gives you clear power: **Mode for scope, flags for granularity.**
|
||||||
@@ -205,16 +205,88 @@ test.describe('Website Pages - TypeORM Integration', () => {
|
|||||||
!msg.includes('connection refused') &&
|
!msg.includes('connection refused') &&
|
||||||
!msg.includes('failed to load resource') &&
|
!msg.includes('failed to load resource') &&
|
||||||
!msg.includes('network error') &&
|
!msg.includes('network error') &&
|
||||||
!msg.includes('cors') &&
|
!msg.includes('cors');
|
||||||
!msg.includes('api');
|
});
|
||||||
|
|
||||||
|
// Check for critical runtime errors that should never occur
|
||||||
|
const criticalErrors = errors.filter(error => {
|
||||||
|
const msg = error.message.toLowerCase();
|
||||||
|
return msg.includes('no queryclient set') ||
|
||||||
|
msg.includes('use queryclientprovider') ||
|
||||||
|
msg.includes('console.groupcollapsed is not a function') ||
|
||||||
|
msg.includes('console.groupend is not a function');
|
||||||
});
|
});
|
||||||
|
|
||||||
if (unexpectedErrors.length > 0) {
|
if (unexpectedErrors.length > 0) {
|
||||||
console.log(`[TEST DEBUG] Unexpected errors on ${path}:`, unexpectedErrors);
|
console.log(`[TEST DEBUG] Unexpected errors on ${path}:`, unexpectedErrors);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow some errors in test environment due to network/API issues
|
if (criticalErrors.length > 0) {
|
||||||
expect(unexpectedErrors.length).toBeLessThanOrEqual(0);
|
console.log(`[TEST DEBUG] CRITICAL errors on ${path}:`, criticalErrors);
|
||||||
|
throw new Error(`Critical runtime errors on ${path}: ${JSON.stringify(criticalErrors)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail on any unexpected errors including DI binding failures
|
||||||
|
expect(unexpectedErrors.length).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detect DI binding failures and missing metadata on boot', async ({ page }) => {
|
||||||
|
// Test critical routes that would trigger DI container creation
|
||||||
|
const criticalRoutes = [
|
||||||
|
'/leagues',
|
||||||
|
'/dashboard',
|
||||||
|
'/teams',
|
||||||
|
'/drivers',
|
||||||
|
'/races',
|
||||||
|
'/leaderboards'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const path of criticalRoutes) {
|
||||||
|
const capture = new ConsoleErrorCapture(page);
|
||||||
|
|
||||||
|
const response = await page.goto(`${WEBSITE_BASE_URL}${path}`);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Check for 500 errors
|
||||||
|
const status = response?.status();
|
||||||
|
if (status === 500) {
|
||||||
|
console.log(`[TEST DEBUG] 500 error on ${path}`);
|
||||||
|
const bodyText = await page.textContent('body');
|
||||||
|
console.log(`[TEST DEBUG] Body content: ${bodyText?.substring(0, 500)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for DI-related errors in console
|
||||||
|
const errors = capture.getErrors();
|
||||||
|
const diErrors = errors.filter(error => {
|
||||||
|
const msg = error.message.toLowerCase();
|
||||||
|
return msg.includes('binding') ||
|
||||||
|
msg.includes('metadata') ||
|
||||||
|
msg.includes('inversify') ||
|
||||||
|
msg.includes('symbol') ||
|
||||||
|
msg.includes('no binding') ||
|
||||||
|
msg.includes('not bound');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for React Query provider errors
|
||||||
|
const queryClientErrors = errors.filter(error => {
|
||||||
|
const msg = error.message.toLowerCase();
|
||||||
|
return msg.includes('no queryclient set') ||
|
||||||
|
msg.includes('use queryclientprovider');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (diErrors.length > 0) {
|
||||||
|
console.log(`[TEST DEBUG] DI errors on ${path}:`, diErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryClientErrors.length > 0) {
|
||||||
|
console.log(`[TEST DEBUG] QueryClient errors on ${path}:`, queryClientErrors);
|
||||||
|
throw new Error(`QueryClient provider missing on ${path}: ${JSON.stringify(queryClientErrors)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail on DI errors or 500 status
|
||||||
|
expect(diErrors.length).toBe(0);
|
||||||
|
expect(status).not.toBe(500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
174
tests/integration/website-di-container.test.ts
Normal file
174
tests/integration/website-di-container.test.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
|
import { createContainer } from '../../apps/website/lib/di/container';
|
||||||
|
import {
|
||||||
|
SESSION_SERVICE_TOKEN,
|
||||||
|
LEAGUE_MEMBERSHIP_SERVICE_TOKEN,
|
||||||
|
LEAGUE_SERVICE_TOKEN,
|
||||||
|
AUTH_SERVICE_TOKEN,
|
||||||
|
DRIVER_SERVICE_TOKEN,
|
||||||
|
TEAM_SERVICE_TOKEN,
|
||||||
|
RACE_SERVICE_TOKEN,
|
||||||
|
DASHBOARD_SERVICE_TOKEN,
|
||||||
|
LOGGER_TOKEN,
|
||||||
|
CONFIG_TOKEN,
|
||||||
|
LEAGUE_API_CLIENT_TOKEN,
|
||||||
|
AUTH_API_CLIENT_TOKEN,
|
||||||
|
DRIVER_API_CLIENT_TOKEN,
|
||||||
|
TEAM_API_CLIENT_TOKEN,
|
||||||
|
RACE_API_CLIENT_TOKEN,
|
||||||
|
} from '../../apps/website/lib/di/tokens';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test for website DI container
|
||||||
|
*
|
||||||
|
* This test verifies that all critical DI bindings are properly configured
|
||||||
|
* and that the container can resolve all required services without throwing
|
||||||
|
* binding errors or missing metadata errors.
|
||||||
|
*
|
||||||
|
* This is a fast, non-Playwright test that runs in CI to catch DI issues early.
|
||||||
|
*/
|
||||||
|
describe('Website DI Container Integration', () => {
|
||||||
|
let container: ReturnType<typeof createContainer>;
|
||||||
|
let originalEnv: NodeJS.ProcessEnv;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// Save original environment
|
||||||
|
originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
// Set up minimal environment for DI container to work
|
||||||
|
// The container needs API_BASE_URL to initialize
|
||||||
|
process.env.API_BASE_URL = 'http://localhost:3001';
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL = 'http://localhost:3001';
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
|
// Create the container once for all tests
|
||||||
|
container = createContainer();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
// Restore original environment
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates container successfully', () => {
|
||||||
|
expect(container).toBeDefined();
|
||||||
|
expect(container).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves core services without errors', () => {
|
||||||
|
// Core services that should always be available
|
||||||
|
expect(() => container.get(LOGGER_TOKEN)).not.toThrow();
|
||||||
|
expect(() => container.get(CONFIG_TOKEN)).not.toThrow();
|
||||||
|
|
||||||
|
const logger = container.get(LOGGER_TOKEN);
|
||||||
|
const config = container.get(CONFIG_TOKEN);
|
||||||
|
|
||||||
|
expect(logger).toBeDefined();
|
||||||
|
expect(config).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves API clients without errors', () => {
|
||||||
|
// API clients that services depend on
|
||||||
|
const apiClients = [
|
||||||
|
LEAGUE_API_CLIENT_TOKEN,
|
||||||
|
AUTH_API_CLIENT_TOKEN,
|
||||||
|
DRIVER_API_CLIENT_TOKEN,
|
||||||
|
TEAM_API_CLIENT_TOKEN,
|
||||||
|
RACE_API_CLIENT_TOKEN,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const token of apiClients) {
|
||||||
|
expect(() => container.get(token)).not.toThrow();
|
||||||
|
const client = container.get(token);
|
||||||
|
expect(client).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves auth services including SessionService (critical for Symbol(Service.Session))', () => {
|
||||||
|
// This specifically tests for the Symbol(Service.Session) binding issue
|
||||||
|
expect(() => container.get(SESSION_SERVICE_TOKEN)).not.toThrow();
|
||||||
|
expect(() => container.get(AUTH_SERVICE_TOKEN)).not.toThrow();
|
||||||
|
|
||||||
|
const sessionService = container.get(SESSION_SERVICE_TOKEN);
|
||||||
|
const authService = container.get(AUTH_SERVICE_TOKEN);
|
||||||
|
|
||||||
|
expect(sessionService).toBeDefined();
|
||||||
|
expect(authService).toBeDefined();
|
||||||
|
|
||||||
|
// Verify the services have expected methods
|
||||||
|
expect(typeof sessionService.getSession).toBe('function');
|
||||||
|
expect(typeof authService.login).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves league services including LeagueMembershipService (critical for metadata)', () => {
|
||||||
|
// This specifically tests for the LeagueMembershipService metadata issue
|
||||||
|
expect(() => container.get(LEAGUE_SERVICE_TOKEN)).not.toThrow();
|
||||||
|
expect(() => container.get(LEAGUE_MEMBERSHIP_SERVICE_TOKEN)).not.toThrow();
|
||||||
|
|
||||||
|
const leagueService = container.get(LEAGUE_SERVICE_TOKEN);
|
||||||
|
const membershipService = container.get(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
|
||||||
|
|
||||||
|
expect(leagueService).toBeDefined();
|
||||||
|
expect(membershipService).toBeDefined();
|
||||||
|
|
||||||
|
// Verify the services have expected methods
|
||||||
|
expect(typeof leagueService.getAllLeagues).toBe('function');
|
||||||
|
expect(typeof membershipService.getLeagueMemberships).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves domain services without errors', () => {
|
||||||
|
// Test other critical domain services
|
||||||
|
expect(() => container.get(DRIVER_SERVICE_TOKEN)).not.toThrow();
|
||||||
|
expect(() => container.get(TEAM_SERVICE_TOKEN)).not.toThrow();
|
||||||
|
expect(() => container.get(RACE_SERVICE_TOKEN)).not.toThrow();
|
||||||
|
expect(() => container.get(DASHBOARD_SERVICE_TOKEN)).not.toThrow();
|
||||||
|
|
||||||
|
const driverService = container.get(DRIVER_SERVICE_TOKEN);
|
||||||
|
const teamService = container.get(TEAM_SERVICE_TOKEN);
|
||||||
|
const raceService = container.get(RACE_SERVICE_TOKEN);
|
||||||
|
const dashboardService = container.get(DASHBOARD_SERVICE_TOKEN);
|
||||||
|
|
||||||
|
expect(driverService).toBeDefined();
|
||||||
|
expect(teamService).toBeDefined();
|
||||||
|
expect(raceService).toBeDefined();
|
||||||
|
expect(dashboardService).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves all services in a single operation (full container boot simulation)', () => {
|
||||||
|
// This simulates what happens when the website boots and needs multiple services
|
||||||
|
const tokens = [
|
||||||
|
SESSION_SERVICE_TOKEN,
|
||||||
|
LEAGUE_MEMBERSHIP_SERVICE_TOKEN,
|
||||||
|
LEAGUE_SERVICE_TOKEN,
|
||||||
|
AUTH_SERVICE_TOKEN,
|
||||||
|
DRIVER_SERVICE_TOKEN,
|
||||||
|
TEAM_SERVICE_TOKEN,
|
||||||
|
RACE_SERVICE_TOKEN,
|
||||||
|
DASHBOARD_SERVICE_TOKEN,
|
||||||
|
LOGGER_TOKEN,
|
||||||
|
CONFIG_TOKEN,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Resolve all tokens - if any binding is missing or metadata is wrong, this will throw
|
||||||
|
const services = tokens.map(token => {
|
||||||
|
try {
|
||||||
|
return container.get(token);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to resolve token ${token.toString()}: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify all services were resolved
|
||||||
|
expect(services.length).toBe(tokens.length);
|
||||||
|
services.forEach((service, index) => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
expect(service).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws clear error for non-existent bindings', () => {
|
||||||
|
const fakeToken = Symbol.for('Non.Existent.Service');
|
||||||
|
|
||||||
|
expect(() => container.get(fakeToken)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user